feat(smash): a screen that says what the game already knows - #1018
Merged
Conversation
Super Smash Mobs enforced ability cooldowns, recorded who smashed whom and ran a five-phase match, and a player could see almost none of it. The cooldown existed only as a line of red text after a press had already been refused; the kill credit reached a broadcast chat line read by everybody except the person it happened to; and BossEvent and SetSubtitleText were sent zero times by this server. Three surfaces, one rule: the game already knows, so say so before it matters rather than after. The experience bar is the recharge of the ability in the slot you are holding. Full with no number when it is ready, filling from empty with the whole seconds beside it while it is not, and empty with no number for a slot holding nothing, which is a third state neither of the others can produce. Filling and not draining, because vanilla fills that bar as experience accumulates and a full bar therefore already reads as "you have the thing"; draining would put it at its fullest at the exact moment the ability cannot be used. It follows a mirrored SelectedSlot rather than the last ability fired, and floors rather than rounds, so a bar that reads full and an ability that is ready are the same statement. The bar across the top is the knockback percentage, which is the read the mode is played on and which no client draws. It is not invented: the knockback model's health term is 1 + 0.1 * missing_health, so the percentage is that term with the one taken off, and a test solves it against `strength` rather than restating its arithmetic. Green, yellow and red at the multipliers those bands mean. Outside a match the same bar carries what the player should be watching instead: how many more people the lobby needs, then how long is left, then the result. The titles punctuate the match. The last three seconds of the prepare timer get a digit, the start gets a word, the end names the winner, and a death names the killer in the subtitle -- which is the whole reason it is a title now, because until this the middle of your own screen carried a life count and no answer to the only question a player asks there. Title and subtitle travel as one value. The protocol makes them separable and the separation is a trap: the subtitle packet only stores a line and the next title is what draws both, so a subtitle written second appears under the wrong title and one never cleared outlives the title it was written for. Carrying them together lets the adapter always write times, then subtitle, then title, and `tools/hud-check.py` reads that order off the wire, which is the only place it is observable. Both surfaces are diffed against what the client was last told, initialised to the state a fresh client is already in, and quantised to sixty-four steps. Measured by the new gate: 3,065 boss bar packets in thirty seconds before, 1,135 after, for a bar 182 pixels wide. What the gate found that no unit test could: two "3, 2, 1" sequences back to back, because both counting phases cross those boundaries. Digits are now the prepare timer's alone; the lobby's sixty second wait belongs on the bar. Deliberately left, and each for a reason rather than for time: * Nametags carry no percentage and no last-life colour. That needs SetPlayerTeam plumbing a sibling change is already near, and the sidebar already colours a name by its life tier from the same table. * The sidebar carries no percentage. Its own documentation is about not redrawing a panel that changes continuously, and a percentage moves under every kit's health regeneration; quantising it enough to stop the churn would put a number on screen that is not the number. * The experience bar shows a cooldown and not a charge or an energy bar. One bar carries one quantity, and two that both read "filling towards go" cannot be told apart on it. * No match timer. A twenty minute timeout nobody has reached is not something a player acts on, and it would have spent the other half of the only always-visible strip on the screen saying so. One thing outside the feature came with it. `smash-hotbar-e2e` landed with a literal port offset of 6000, which is `completions-e2e`'s, and a literal is exactly what the new `e2ePortsDistinct` eval guard cannot see. Both it and this gate are now in `e2eOffsets`, so the guard covers every gate rather than most of them. Verified locally: `nix run .#test` (677 passed), `nix run .#lint`, `nix run .#fmt --check`, and `nix run .#smash-hud-e2e` (24 checks, eight real clients through a whole countdown and into a match), and `nix run .#smash-e2e` for regressions. Every guard was broken and watched to fail before being trusted.
Benchmark Results for generalComparing to 23395b9 |
Codecov Report❌ Patch coverage is @@ Coverage Diff @@
## main #1018 +/- ##
==========================================
+ Coverage 47.16% 47.58% +0.42%
==========================================
Files 242 243 +1
Lines 27177 27608 +431
Branches 1096 1118 +22
==========================================
+ Hits 12817 13137 +320
- Misses 14124 14235 +111
Partials 236 236
🚀 New features to boost your workflow:
|
andrewgazelka
added a commit
that referenced
this pull request
Jul 28, 2026
…t has two #1018 put one boss bar on the wire, hand rolled in a game event's adapter. This is the API it should have been, and the two bars the operator asked for on top of it. A bar is an entity, its audience is `(ShownTo, player)` edges, and what each viewer was last told is the data on `(Sent, viewer)`. That last choice is what removes the two cases that are usually bugs: a mid-match joiner has no pair, so the next tick sends them `Add`, and every way a bar stops being shown ends with that pair going away, which one `OnRemove` observer turns into a `Remove`. There is no join or leave handler in this change. One field moves and it is that field's narrow operation; two or more and it is a single fresh `Add`, which is both atomic and smaller than the updates it replaces. The wire gate found that one: a lobby bar becoming a countdown was briefly a countdown in the lobby's blue. `/serverload`, Admin, opt in, puts the host's own CPU and RSS on two bars. CPU is `getrusage`, not `ps`, so the first sample reports nothing rather than a lifetime average. Both are drawn against the point the machine stops being able to give more and print that ceiling beside the reading, so the fill is the quotient of the two numbers in the bar's own label and `hud-check.py` divides them to check it. `smash-hud-e2e` goes from 25 checks to 48, including one that reads every boss bar packet of the whole run and asserts none of them said anything the client already knew. It also fixes a check #1019 left red on main.
andrewgazelka
added a commit
that referenced
this pull request
Jul 28, 2026
…t has two #1018 put one boss bar on the wire, hand rolled in a game event's adapter. This is the API it should have been, and the two bars the operator asked for on top of it. A bar is an entity, its audience is `(ShownTo, player)` edges, and what each viewer was last told is the data on `(Sent, viewer)`. That last choice is what removes the two cases that are usually bugs: a mid-match joiner has no pair, so the next tick sends them `Add`, and every way a bar stops being shown ends with that pair going away, which one `OnRemove` observer turns into a `Remove`. There is no join or leave handler in this change. One field moves and it is that field's narrow operation; two or more and it is a single fresh `Add`, which is both atomic and smaller than the updates it replaces. The wire gate found that one: a lobby bar becoming a countdown was briefly a countdown in the lobby's blue. `/serverload`, Admin, opt in, puts the host's own CPU and RSS on two bars. CPU is `getrusage`, not `ps`, so the first sample reports nothing rather than a lifetime average. Both are drawn against the point the machine stops being able to give more and print that ceiling beside the reading, so the fill is the quotient of the two numbers in the bar's own label and `hud-check.py` divides them to check it. `smash-hud-e2e` goes from 25 checks to 48, including one that reads every boss bar packet of the whole run and asserts none of them said anything the client already knew. It also fixes a check #1019 left red on main.
andrewgazelka
added a commit
that referenced
this pull request
Jul 28, 2026
…t has two (#1026) `BossEvent` reached the wire for the first time in #1018, as one bar, hand rolled in a game event's adapter: one UUID per player, `Add` for every change, and a diff in the game half to stop it resending. That was correct and it was measured, and it did not survive the next ask, which was two more bars carrying something that is not a game concept at all. ## A bar is an entity and its audience is a set of edges ```rust let bar = world .entity() .add(BossBar::id()) .set(Title(Text::text("CPU 240% of 1600%").color(NamedColor::Aqua))) .set(Progress(0.15)) .add((ShownTo, operator)); ``` and later `bar.set(Progress(0.31))`, which sends one `UpdateProgress` to each viewer and nothing else. `smash`'s `Server::set_boss_bar` is now four `set`s on one of these, and the packet it used to build is gone. The load-bearing choice is not the components; it is where the per-viewer sent state lives. `Sent` is the data on the `(Sent, viewer)` pair, holding exactly what that client was last told, and it is what makes the two cases that are usually bugs stop being code: * **Joining.** A player who was not there has no `(Sent, bar)` pair, so the next tick sends them `Add`. There is no join handler in this change. * **Leaving.** A bar is destroyed, a player disconnects, or an audience shrinks. All three end with the pair going away -- flecs does the first two itself under `(OnDeleteTarget, Remove)` -- and one `OnRemove` observer on `(Sent, Wildcard)` sends the `Remove`. Teardown cannot leak, because the fact that says "this client has this bar" and the thing that gets cleaned up are the same fact. There is no explicit join or leave path anywhere in `boss_bar.rs`. That was the test of whether the shape was right. ## The change the wire gate found A change of state usually moves several fields at once, each operation is its own packet, and between the first and the last the client is holding a bar that is half one state and half the next. There is no ordering that fixes it: put the title last and a countdown ticking over draws the next second's fill under this second's number; put it first and a lobby bar becoming a countdown is a countdown in the lobby's blue. `smash-hud-e2e` caught the second of those as a real failure -- one of #1018's own checks, `and it is yellow, not the lobby's blue`, against a bar that was briefly exactly that. So one field moving is that field's narrow operation, and two or more is a single fresh `Add`. `Add` is *also* the smaller packet: every operation repeats the sixteen byte id, so two updates already cost more than the one that carries all four fields, and `BossHealthOverlay.add` is a `put` into a map keyed on the id, so it replaces the bar in place. Packet count is unchanged from #1018's 1,135 -- both send one packet per change -- and each packet is now at most the size of the `Add` it replaced. ## The two bars that carry the host `/serverload`, Admin, opt in. Fifteen people in a lobby staring at server telemetry is a strange default and three stacked bars is most of the strip. **CPU is `getrusage(RUSAGE_SELF)`, not `ps`.** `ru_utime + ru_stime` is a cumulative counter, so a rate over a window is the only reading it can give and the first sample reports that it has nothing yet. `ps` answers the lifetime average instead, which is a different question wearing the same units: a server busy for a minute an hour ago reads busy under `ps` forever. **Memory is RSS**, which is what the kernel counts when it decides who to kill. `/proc/self/statm` field one on Linux, `proc_pidinfo`/`PROC_PIDTASKINFO` on darwin. The Linux path cannot run on the box the gates run on, so the parser is a pure function over a string with a fixture whose seven fields all hold different numbers -- an off-by-one cannot pass it. ### The bar that has no natural full Neither quantity is a fraction. A process can use more than one core's worth of CPU, and it can allocate until the machine refuses. So both are drawn against the point where the machine stops being able to give more -- every core it has, every byte of physical memory -- and both print that ceiling beside the reading: ``` CPU 240% of 1600% MEM 3.20 GiB of 128.0 GiB ``` which turns the choice into something checkable rather than a matter of taste: > **the fill is the quotient of the two numbers in the bar's own label.** `hud-check.py` reads both numbers off the wire and divides. A ceiling nobody printed cannot satisfy that, and neither can one invented to make the bar look busy. It is also what keeps the label unnormalised: the honest figure is the numerator, and the fill is that same fact taken against the denominator standing next to it. 200% is two cores, which is what `top` says and what the question meant. Cores come from `available_parallelism`, not the raw processor count, because in a container the honest ceiling is what the container is allowed. ## The gate `nix run .#smash-hud-e2e`, 48 checks, up from 25. `decode_boss_event` read only `Add` and returned `None` for everything else, because `Add` was all the server sent. Left alone against this server it would not have failed -- it would have reported that no boss bar ever arrived, which is a silent pass. It now decodes all six operations into a model of the client's own `BossHealthOverlay`, so #1018's twenty-five checks ask the same questions of the same states, and the operations are kept separately as the only place the diffing is visible at all. One check that was already there was also red before this branch touched anything. #1019 moved `LobbyConfig::min_players` from four to two, and the hub bar's `1/4` was written into `hud-check.py` as a literal, so the gate has been failing on `main` against a server saying `1/2`. #1019 rewrote six `tests/lobby.rs` tests for exactly this reason and the one in `tools/` was missed, because nothing in CI runs the wire gates. It now reads the denominator off the bar and additionally checks that the fill under the label is `1/needed`, which is a stronger claim than the literal it replaces and cannot go stale again. Filed as ENG-10878. The new checks, beyond the two bars' own numbers: * An update sends only the update, over **every** boss bar packet of the whole run rather than at one moment. Three rules -- no packet says anything the client already knew, a whole bar is only resent where two or more fields moved, nothing arrives for a bar the client was never given -- checked against 849 packets in the run below. * A mid-match joiner gets the bar for the match it walked into, as an `Add` carrying the whole thing. * A second viewer joining a *running* bar's audience gets it under the same id the first one holds. The load bars are the only ones in the game with an audience of more than one person, so they are the only place this is observable. * Leaving that audience sends a `Remove` to that viewer and nothing to anybody else. * A viewer disconnecting with a bar on their screen leaves the server drawing everybody else's. ## Broken, and watched to fail Every new guard was inverted and run. Selected output. ### The parser reads `/proc/self/statm`'s first field instead of its second ``` statm_reads_the_resident_field_and_not_a_neighbour left: Some(287072256) right: Some(27807744) a_statm_line_that_is_not_one_reads_as_nothing left: Some(287072256) right: None ``` The fixture's seven fields all hold different numbers, so the off-by-one is a wrong answer rather than a coincidence, and the second test caught it too. ### The CPU label is normalised, which is the whole point of the ask ``` the_cpu_label_is_unnormalised_and_the_fill_is_not left: "CPU 15% of 100%" right: "CPU 240% of 1600%" ``` and on the wire, with the same edit: ``` FAIL the CPU label is unnormalised, so its ceiling is every core and not one: CPU 0% of 100% ``` ### The first CPU sample answers with the lifetime average, the way `ps` does ``` the_first_sample_reports_nothing_rather_than_an_average left: Some(90.0) right: None ``` ### `Sent` is cleaned up with `Delete` rather than `Remove` ``` a_viewer_leaving_removes_only_their_own_record one viewer leaving destroyed the bar ``` Which is the whole teardown design failing in the direction that would be hardest to notice in play: one person quits and the bar goes off everybody's screen. ### Every change resends the whole bar ``` FAIL and a whole bar was only ever resent where two or more fields moved at once: 827 of 872 [('H1', '6879705f', ['title']), ('H1', '6879705f', ['title']), ...] FAIL a new CPU reading that moves only the label costs one packet carrying only the label: ['add', 'add', 'add', 'add', 'add'] ``` ### A client is recorded as having a bar it was never sent ``` FAIL and nothing arrived for a bar the client had never been given: 39 of 981 [('H1', '6879705f', 'update_name'), ...] FAIL the hub puts a boss bar up saying what it is waiting for: [] ``` The second line is what this failure looks like from the outside, and it is why the first one exists: a bar that never arrives is indistinguishable from a server that never drew one, until something is watching for updates to a bar nobody has. ### The id is per viewer rather than per bar, and the teardown observer is gone ``` FAIL a viewer joining a running bar's audience is sent it under the same id the others hold: ['...0584', '...5816a', '...5816d', '...58784'] FAIL with a Remove, while the viewer who is still watching keeps theirs: [] / ['...057e', '...5787e', '...57e6a', '...57e6d'] ``` Four ids where there should be two, because every viewer minted its own, and no `Remove` at all where a viewer had just left the audience. ## Verified Local only, per ENG-10817. * `nix run .#test` -- 704 passed, 1 skipped * `nix run .#smash-hud-e2e` -- 48 checks, 0 failed * `nix run .#fmt` -- clean * `nix run .#lint` -- **one failure, and it is not this branch's**: ``` error: unused import: `flecs_ecs::prelude` --> events/smash/tests/abilities.rs:595:9 ``` Reproduced against a detached worktree at `origin/main` with its own `CARGO_TARGET_DIR`, running the same command `flake.nix:182` runs, with no part of this branch present: ``` $ CARGO_TARGET_DIR=/tmp/mainlint-target cargo clippy --all-targets --all-features -- -D warnings error: unused import: `flecs_ecs::prelude` --> events/smash/tests/abilities.rs:595:9 rc=101 ``` Filed as ENG-10890 rather than fixed here, because it belongs to #1023 and not to this change. Worth knowing while chasing it: plain `cargo clippy -p smash --tests` over the same tree exits 0, because `unused_imports` is only a failure under the `-- -D warnings` the flake adds -- which is how it got past #1023's own check, and how it briefly looked like mine. ## Deliberately not built * **No synthetic load past 100% on the wire.** The gate proves the fill stays a fraction, that the ceiling is every core, and that the fill is the quotient of the label's own two numbers; it does not observe a reading above one core's worth, because forcing the server process over a core from a test client would mean a burn command in production code. The unnormalised case is a unit test with the numbers injected: 2.4 cores of 16 reads `CPU 240% of 1600%`, and 2.4 cores of 1 reads `CPU 240% of 100%` with a full bar. * **The `/proc/self/statm` fixture is written to `proc(5)`, not captured off a running host.** The gates run on darwin. What the fixture does prove is the field index, which is the failure that would ship, because every field holds a different number. * **The sidebar is still hand rolled in smash's adapter.** It has the same argument as the boss bar and it should follow, but porting it is a second change and this one already moves a gate. * **`ShownToEveryone` has no caller.** It is in the API because "everyone" is a rule rather than a list -- a player who connects a minute from now is in it -- and it is covered by the drive system's own path, but nothing in the tree shows a bar to everybody yet. ## Also filed **ENG-10878**: `smash-hud-e2e` was red on `main` before this branch, as above. Fixed here. The follow-up worth having is a job that runs the eight gates in `e2eOffsets`, because a change that breaks only one of them is invisible today until somebody runs it by hand. **ENG-10871**: `/perms set <player> Admin` is gated at `Normal`, so any player who can connect can make themselves Admin. The derive gates a whole clap enum at once, so `Get` and `Set` cannot differ. This gate depends on the hole -- it promotes its own clients, because there is no other way for a test client to reach an Admin command -- so closing it needs a way to seed a group at startup landed alongside. `hud-check.py` names the ticket at the line that depends on it. --- Written by Claude (Opus 5) with human direction.
andrewgazelka
added a commit
that referenced
this pull request
Jul 28, 2026
#1027) ## Effect `nix run .#smash-e2e` passes again, all 15 kits. It has been red since #1019. ## What broke #1019 moved `LobbyConfig::default()` from 4/8 to 2/4, which the operator asked for. `tools/smash-match.py` had those numbers written into it a second time -- as `max(1, self.args.clients - 1)`, as "`min_players` is four" in its docstring, and as "one short of the lobby's minimum" at the call site -- so the sweep's three clients went from below the threshold to above it. The countdown started, the match committed, and every later kit change was refused with `You cannot change kit once the game has started.` Reproduced on e4bbcc2 before changing anything: ``` 0.60s PROVED lobby started a match: server announced the countdown with 3 players connected 0.68s PROVED four in play: 3 clients hold a Login packet: P1=67110240, P2=1377, P3=1378 30.79s [P3 ] <- chat: §cYou cannot change kit once the game has started. 45.78s [P3 ] <- chat: §cYou cannot change kit once the game has started. 60.83s [P1 ] <- chat: §cYou cannot change kit once the game has started. ``` ## It was not the minimum that fired, and that decides the fix The countdown started 0.6 seconds in, not at `min_players`. `countdown_for` checked the three-quarters band before the minimum, so at 2/4 three players satisfy `3 * 4 >= 4 * 3` and take the 30-second band. So `min_players - 1` was never a sound expression of "a roster that cannot start a countdown". The real condition depends on `full_players` too. At 4/8 the subtraction worked by luck. ## Why not read the threshold off the wire #1018 puts `Waiting for players {n}/{min_players}` on a boss bar, and a harness that reads its own limit off that packet cannot disagree with the server. I rejected it as the fix, because it cannot be one. The sweep needs two clients to prove `hurts_target` and to call `wound_attacker` for `heals_caster`; at `min_players: 2` the number it would read off the wire is one, and a one-client sweep goes green while proving less. Observation cannot manufacture headroom, only control can. The bar also advertises only `min_players`, which per the section above is not the number that decides this. ## So the gate declares the lobby, and the harness checks it got it - `LobbyConfig::from_env()` reads `SMASH_MIN_PLAYERS` and `SMASH_FULL_PLAYERS`, in the `SMASH_`-prefixed namespace `hyperion_event_runner::run` already reads the address and certificates from. Not added to the shared `Args`, which bedwars would then carry and never read. - `nix/e2e.nix`'s `mkCheck` gained `serverEnv`. `smashGateLobby` in `flake.nix` declares 4/8 and a sweep roster of 3 in one place and feeds both the app and the check. - `smash-match.py` takes `--sweep-clients` and subtracts nothing. The docstring no longer says four. 4/8 and not just a higher minimum, because `full_players` is what gates the three-quarters band. These are the pre-#1019 numbers, under which three players start nothing even by the old band order, so the gate does not depend on the `countdown_for` change below. ## The guard is the repro `hub_only` fails the run if the lobby leaves the hub while the sweep needs it, checked before the sweep and again before each kit. It reads the phase off the server's own broadcasts rather than recomputing a threshold, so it stays right whatever the numbers become and is not a second copy of them. Broken deliberately, by pointing `smashGateLobby.env` at production's 2/4: ``` 0.72s SWEEP ABANDONED the lobby left the hub before the sweep began. 3 clients is enough for this server to start a countdown, so kits can no longer be changed and nothing further would be proved. Either --sweep-clients is too many for this lobby, or the server needs a higher SMASH_MIN_PLAYERS/SMASH_FULL_PLAYERS. 0.72s RESULT: 10 of 12 steps did not happen rc=1 ``` 571 seconds and a hang become 0.72 seconds and a cause. Restored to 4/8 afterwards. Breaking it is also what found a real bug in it. The first version stopped the run but only recorded its reason in `sweep_failures`, which `sweep` prints at the end of a sweep it no longer reached -- so the run failed with the reason nowhere in the transcript, and then went on to run a mostly-passing match underneath the failure. It now logs directly and returns straight to the report. ## countdown_for checks the minimum first Fixed here rather than filed, because it is a reordering in a file this PR already touches and its failure mode is silent. `min_players` was a partial lie: at `min_players: 4, full_players: 4`, three players started a countdown under a stated minimum of four. Provably inert for both configurations anyone runs. `the_live_configurations_are_unaffected_by_the_minimum_check` keeps the old three-branch order in the test and asserts the new one agrees with it at every count from 0 to `full_players * 3`, for 2/4 and 4/8. `a_lobby_never_starts_below_its_own_minimum` is the case that would have caught it, over every config from 1/1 to 12/12. ## Checks `cargo nextest run -p smash`: 257 passed. `cargo clippy -p smash --all-targets`: clean. `cargo fmt --check`: clean. `nix run .#smash-e2e`, all 15 kits and all 12 claims: ``` 254.43s === what the match proved === 254.43s proved the server published its ability registry 254.43s 51 abilities across 15 kits (Blaze, Chicken, Cow, Creeper, Enderman, Guardian, Iron Golem, Skeleton, Sky Squid, Slime, Snowman, Spider, Wither Skeleton, Wolf, Zombie), every one of them declaring what a client would see 254.43s proved every declared ability did what it declared 254.43s 51 abilities fired by a real client, each one checked against the effects its own registry entry names 254.43s proved every declared ability was heard 254.43s 51 abilities fired by a real client, each one answered by a ClientboundSoundPacket carrying the vanilla sound event its own registry entry names 254.43s proved cooldowns refused a second use 254.43s 47 abilities answered a second right-click inside their cooldown with 'That ability is recharging.' on the action bar 254.43s proved four in play 254.43s proved lobby started a match 254.43s server announced the countdown with 4 players connected 254.43s proved kits equipped 254.43s proved arena is a committed map 254.43s proved knockback from an ability 254.43s proved a hit was heard where it landed 254.43s proved life lost and respawned 254.43s proved match ended, back in the hub 254.43s RESULT: a whole match ran at protocol 776 rc=0 ``` The sweep is 73 seconds for all 51 abilities, not the 571 seconds the broken run took: that figure was time spent on refused kit changes, not real work. The check's 480s timeout has room. ## Not done, and one thing found I did not run the `smash-e2e` **check** derivation, only the app. CI covers it and the timing above says it fits. ENG-10889: `tools/smash-selector.py` has this same defect twice, `FULL_PLAYERS = 8` with a comment citing `LobbyConfig::default` and "Three, which is one short of `min_players`" above its own `connect(3)`. `smash-selector-e2e` is likely broken by #1019 in exactly the way this gate was. Not run and not fixed here. `hotbar-check.py:22` and `smash-map-check.py:1141` have stale prose about the same numbers but still behave.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Super Smash Mobs enforced ability cooldowns, recorded who smashed whom and ran a
five-phase match, and a player could see almost none of it. The cooldown existed
only as a line of red text after a press had already been refused. The kill
credit reached a broadcast chat line read by everybody except the person it
happened to.
BossEventandSetSubtitleTextwere sent zero times by thisserver.
Three surfaces, one rule: the game already knows, so say so before it matters
rather than after.
The experience bar is the recharge of the slot you are holding
Full with no number when the ability is ready, filling from empty with the whole
seconds beside it while it is not, and empty with no number for a slot holding
nothing. Those two numbers together are what make the third state expressible:
neither of the others can produce an empty bar with no count.
Filling and not draining. Vanilla fills that bar as experience accumulates, so a
full bar already reads as "you have the thing" to anybody who has played
Minecraft. Draining would put it at its fullest at the exact moment the ability
cannot be used, which inverts the one fact it is there to carry.
It follows a mirrored
SelectedSlotand not the last ability fired, which readsidentically until a player holds a different item, and it floors rather than
rounds so a bar that reads full and an ability that is ready are the same
statement rather than nearly the same one.
The level number is the whole seconds left, rounded up. The bar is the
glanceable half and is deliberately imprecise; the number answers the other
question, which is not "am I ready" but "how long until I am".
The bar across the top is the knockback percentage
The signature read of the mode, and no client draws it: hearts say how much
damage you have left to take, percent says what the next hit will do with it.
It is not invented.
knockback::strength's health term is1 + 0.1 * missing_health, so the percentage is that term with the one takenoff and multiplied out: 0% fresh, 100% where a hit sends you twice as far. A
test solves the claim against
strengthrather than restatingpercent's ownarithmetic, so changing one without the other fails. Green, yellow and red sit
at the multipliers those bands mean, not at numbers that felt right.
Outside a match the same bar carries whatever the player should be watching
instead: how many more people the lobby needs, then how long is left, then the
result. One bar rather than two. A match-clock bar was the alternative and was
dropped, because a twenty minute timeout nobody has reached is not something a
player acts on and it would have spent the other half of the only
always-visible strip on the screen saying so.
Titles punctuate the match, and a death names the killer
The last three seconds of the prepare timer get a digit. The start gets a word.
The end names the winner, in the title, in the boss bar and in chat. A death
names the killer in the subtitle, which is the whole reason it is a title now:
until this, the middle of your own screen carried a life count and no answer at
all to the only question a player asks there. The credit is not new, it simply
had nowhere to be read.
A title and its subtitle travel as one value. The protocol makes them separable
and the separation is a trap:
ClientboundSetSubtitleTextPacketonly stores aline and the next title is what draws both, so a subtitle written second appears
under the wrong title, and one nobody cleared outlives the title it was written
for. Carrying them together lets the adapter always write times, then subtitle,
then title, and
tools/hud-check.pyreads that order back off the wire, whichis the only place it is observable at all.
What the wire gate found that no unit test could
Two "3, 2, 1" sequences back to back:
['3', '2', '1', '3', '2', '1', 'GO!'].Both counting phases cross those boundaries and both tick out loud, so a window
on both draws the digits twice with a teleport in the middle. Digits are now the
prepare timer's alone.
And 3,065 boss bar packets in thirty seconds for eight clients, because progress
is continuous in three of the five phases. Quantising both surfaces to
sixty-four steps, which is finer than a 182 pixel bar can draw, took it to
1,135.
Deliberately left
Each for a reason rather than for time.
SetPlayerTeamplumbing a sibling change is already near, and the sidebaralready colours a name by its life tier from the same table.
redrawing a panel that changes continuously, and a percentage moves under
every kit's health regeneration; quantising it enough to stop the churn would
put a number on screen that is not the number.
bar carries one quantity, and two that both read "filling towards go" cannot
be told apart on it.
One gap in the brief turned out to be already closed:
lives.rshas broadcastX was smashed by Y!since the death path was written, andsmash-match.pyreads it. What was missing was the victim's own screen, which is what the
subtitle is.
Verified
Local only, per ENG-10817.
nix run .#test-- 677 passed, 1 skippednix run .#lint-- cleannix run .#fmt-- cleannix run .#smash-hud-e2e-- 25 checks, 0 failed, eight real clients through awhole countdown and into a match
nix run .#smash-e2e--RESULT: a whole match ran at protocol 776, rc=0Every guard was broken and watched to fail before being trusted. Selected
output:
Also filed
ENG-10852:
cargo test -p smashsegfaults about once in a hundred runsinside
ecs_init, becausecargo testruns a thread per test and every testopens a
World. Pre-existing, measured at 1 in 160 on origin/main, and invisibleto
nix run .#testbecause nextest forks per test. Ruling my own change out ofit cost about forty minutes.
One thing outside the feature came with it:
smash-hotbar-e2elanded with aliteral port offset of 6000, which is
completions-e2e's, and a literal isexactly what the new
e2ePortsDistincteval guard cannot see. Both it and thisgate are now in
e2eOffsets.Written by Claude (Opus 5) with human direction.