promotional bannermobile promotional banner

Wave Defense

A versatile PvE and PvP engine featuring wave defense, customizable shops, and three competitive game modes for Minecraft
Back to Files

wavedefense-0.9.0.jar

File namewavedefense-0.9.0.jar
Uploader
Rayman_GCRayman_GC
Uploaded
Aug 24, 2026
Downloads
9
Size
1.4 MB
Mod Loaders
Forge
File ID
8721892
Type
R
Release
Supported game versions
  • 1.20.1

Curse Maven Snippet

Forge

implementation fg.deobf("curse.maven:wave-defense-1492909:8721892")

Learn more about Curse Maven

What's new

# Changelog
## [0.9.0] - 2026-08-17 — an arena stops carrying your wallet

The first structural stage. A location described how an admin built an arena *and* held every
player's balance and side, and that one decision is the ancestor of most of the mod's worst bugs.
The two are now separate things.

### What it cost to have them together

The editor held a copy of the whole location, balances included, and pressing Save sent that copy
back — rolling everyone's points to whatever the editor happened to be holding. That is the
973-points bug, and four separate mechanisms were built to contain it: a `RUNTIME` key set the
server refuses to accept, a `PER_PLAYER_RUNTIME` subset, a stripping function, and a second
client-bound save path. Four patches, one wound.

**All four are deleted.** Not because they stopped being needed — because there is nothing left
for them to do. Balances and sides live in `ArenaState`, which is server-side, keyed by arena
name, and never written to disk. What a client never receives it cannot send back, and now a
client cannot receive it because the server does not have it to send.

### A crash no longer leaves money on the table

Balances rode to disk in the locations file. `loadRuntimeState` discards sessions on restart, and
deliberately so — the mobs the wave spawned are gone and a half-finished run can never be
completed — but the balance stayed in the file. Nothing reset it, and entering an arena *adds*
the starting grant rather than setting it. So a server that went down mid-match handed the next
person through the door somebody else's money.

Balances now end with the run, like everything else about it.

### Also fixed on the way

A player's side is now dropped when they leave, alongside their balance. Only the balance was
being cleared, so a stale seat could still count towards a team the player was no longer on.

`syncPlayerData` had a block that resolved the "live" `Location` because the player might be
holding a stale copy. Balances are keyed by name and there is only ever one, so the question it
was dodging cannot be asked — the block is gone rather than updated.

### The split of Location has started

`Location` carried 126 fields and 295 public methods, and the section comments in it had stopped
matching their contents — `playerPoints` sat under "Spawn scaling switches", the PvP spawn list
under "Shop mode". Fields were appended wherever the cursor happened to be, and a comment that
lies is worse than none.

Three groups are out so far: **`MasCompatSettings`** (the optional Mine and Slash tuning, nine
fields read by one class), **`BattleRoyaleSettings`** (the shrinking border, nine settings that
only mean anything together) and **`BoundarySettings`** (the invisible wall and what crossing it
costs, read by the boundary manager, the mob spawner and the trigger evaluator). Each serialises
itself, so adding a field means changing one class with the reader and the writer four lines
apart — instead of two 200-line methods that have to be kept in step by remembering to.

`Location` is down to 781 lines and 233 public methods, from 886 and 295. Nine or so groups
remain.

Two things the boundary cut nearly got wrong, both worth recording because the next nine groups
will meet them again. The rename looked like a prefix match on "Boundary" — but
`isBoundaryParticlesVisible` is a *player's* preference about whether they want to see the ring,
not the admin's decision for everybody, and it lives on client settings. An exact map of method
names was used instead of a prefix. And the block of fields being removed had an `enum`
declaration sitting in the middle of it, which went out with them; it was restored from the lang
file and the manager's switch rather than from memory, and now carries a note saying why the type
stays on `Location` while its fields do not.

**The keys on disk are unchanged.** Nesting them would be tidier and would silently orphan every
setting on every saved arena.

### The net had a hole, and the first cut fell through it

`LocationRoundTripTest` fills every field reflectively and compares what comes back. It could not
fill a *nested settings object* — so the moment the first group was cut, those nine fields
dropped out of the guard's view entirely. It would have gone on passing while a group quietly
stopped being written, which is the exact failure the test exists to prevent.

Fixed, and verified by deleting one field's write from inside a group and by deleting the group's
call from the serializer. Both are now caught by path: `mas.chaosResist`, `mas.level`.

Two other guards had to learn the new shape. The key-coverage scan read `LocationSerializer` and
nothing else, so keys written by a group looked unpersisted; it now scans anything with a
`writeInto(CompoundTag)` — the signature that means "these go on the arena's own tag" — which
deliberately excludes the classes that serialise into a tag of their own. And the round-trip
test's coverage floor was an absolute number that would have to be lowered after every group;
it now names the fields it cannot cover, so a new blind spot has to be acknowledged rather than
absorbed by editing a threshold.

### The portal: moved, and two things fixed while it was open

`PortalSettings` is the fourth group out. Moving it meant reading it, and reading it turned up
two faults that had nothing to do with the move.

**A gate that could not be placed retried twenty times a second.** Placement fails for reasons
that are still true a tick later — nobody outside an arena for it to open next to, no loaded
ground within the radius — and there was no delay at all. So on a quiet server, for every
portal-enabled arena, every tick: build a candidate list from the whole Overworld player list,
pick a target, probe the heightmap up to twelve times, give up, repeat. Indefinitely.

It now waits a second between attempts, which is free: nobody is standing at a gate that does
not exist. The admin's "open now" button clears the delay, because otherwise it would appear to
do nothing and get pressed again.

**A field kept alive only by its own explanation.** `LocationSession.portalOpenUntilMs` carried a
careful javadoc about replacing an older pair of counters that could disagree with reality. The
replacement went further than the note did — whether anybody is inside is asked of the player
list now — and nothing has read or written the field since. Deleted.

Also simplified: the respawn check had two identical branches, one for "no session yet" and one
for "timer expired", both doing the same thing.

**One setting deliberately left behind.** `reEntryCooldownSec` sits next to the portal keys in
the save file and looks like it belongs with them. It does not: it is checked in
`SessionManager.addPlayer`, which is *every* way into an arena — the menu, the hub, a lobby, an
activation zone. Filing it under the gate would have told the next reader something false.

`Location` is down to 760 lines and 222 public methods, from 886 and 295.

### King of the Hill was won three times too fast

`ObjectiveSettings` is the fifth group out — the thirteen settings by which Capture the Point and
King of the Hill decide a winner. Reading them turned up a real fault.

In hold-timer mode the hill clock advanced **once per owned point**:

```java
for (CapturePoint cp : points) {
    String owner = state.getPointOwner(cp.getId());
    if (owner != null) state.addKothHoldTicks(owner, 1);
}
```

King of the Hill is one hill by name, but the capture-point list takes as many as an admin cares
to place and nothing stops them. A side holding three points banked three seconds of hold time
per second — so a match configured as "hold for 180 seconds" was won in sixty. The setting said
one thing and the arena did another.

A side now earns one tick per tick while it holds anything, which is what the setting has always
claimed. On the ordinary single-hill arena the behaviour is bit-for-bit what it was.

The decision moved into `PvpOutcome.holdingTeams` — plain values, no server needed — with five
tests including "two sides each holding something both accrue" and "an unowned point must not
become a side called null". Verified by putting the double-count back: four tests fail.

### Four things checked and found sound

Worth saying plainly rather than padding the list. `isObjectiveFirstToScore` looked unused from
one angle and turned out to be read in three places. The round timer looked as though nothing
initialised it; `PvpRoundState` does. The capture-all-points win condition looked like it might
only apply in score mode; it is evaluated before the mode branch, so it covers both. And the
per-mode settings looked dead until the unified `getObjective*` accessors turned up, which pick
by the arena's current mode — deliberately, so an arena switched between CtP and KotH keeps both
tunings.

`Location` is down to 724 lines and 197 public methods, from 886 and 295.

### Entry equipment: the door stopped deciding what you own

The arena's inventory rules were overruled by **which way you walked in**. A portal entrant was
never relieved of anything and never issued a kit; anyone arriving by the menu, the hub, a lobby
or an activation zone had their belongings stashed, wiped and re-issued. Same arena, two
different games, and no setting anywhere said so. The portal was not misbehaving — it was obeying
a rule nobody had written down, which is why it kept looking broken.

On top of that, `keepInventory` decided two things at once. The wipe and the kit sat inside one
`if`, so **"keep what you are carrying and also hand out the arena's kit" could not be expressed
at all**. Combined with `keepLootOnExit` that made eight arrangements, of which two were coherent
— one of the rest silently deleting everything a player had picked up.

There is one question now, it is asked of the arena, and it has two answers:

- **Come as you are** — nothing taken, nothing issued. Walk in with your own gear, walk out with
  it plus whatever you found. The starting kit is deliberately not handed out here: a kit you
  keep forever would be minted again on every entry.
- **Stash & kit out** — belongings held at the door and returned on the way out. Arrive
  empty-handed, receive the arena's starting kit if one is set, gear up inside. Whether loot
  comes out with you is a sub-option, shown only when it means something.

The doorway still decides *where to put a player back down*. It no longer decides what they own.
A portal entrant who was stashed now gets their gear back — the two exit paths used to be
mutually exclusive only because a portal entrant never had a backup to restore.

**Existing arenas do not change behaviour.** The old flag defaulted to true in both the
constructor and the loader, meaning "let them keep their own gear", so an arena that never
touched the setting maps to *Come as you are* — exactly what it did before. What does change,
deliberately, is that the portal now obeys whatever the arena says.

`EntrySettings` is the sixth group out of `Location`, and it finally gives `reEntryCooldownSec` a
home: it sits beside the portal keys in the save file but is checked on every way in.

Guarded by `theDoorDecidesWhereToStandNotWhatYouKeep`, which reads the condition that actually
guards the backup rather than a window of nearby characters — the doorstep record sits
immediately above it, and a looser check read *its* condition and fired on healthy code.

### Found by auditing the change itself

Three things the move surfaced, all fixed in the same release.

**The PvP leaderboard was recording zeroes.** `endPvpMatch` reads balances twice — once for the
post-match scoreboard, once for the leaderboard record — and between them it drops every player,
which resets their balance. So the record was written from a balance that had just been zeroed,
for every mode that scores on points rather than on objectives. The PvE path has always taken a
snapshot before dropping anybody; the PvP path now does too.

The ordering was wrong before this release as well, but whether it showed depended on two
references happening to be different copies of the same arena. Keying state by name removed that
coincidence and made it certain — which is how it got noticed.

**Deleting an arena left its money behind.** Balances used to be fields on the `Location`, so
deleting one deleted them. They are keyed by arena *name* now, so an arena recreated under an old
name would have inherited the old one's balances and seating. `removeLocation` drops them
explicitly.

**Two methods came across dead.** `getEnemyCount` and a teammates lookup were moved from
`Location` into the new class before anyone checked whether they had callers. They did not — on
`Location` either. Carrying dead code through a refactor is how the object they came from reached
a hundred and twenty-six fields, so they were deleted rather than rehoused.

Two hot paths also stopped resolving the state inside a loop: the teammates sync did it once per
viewer per player, and an info panel once per player per redraw.

### Guards

`ArenaStateTest` pins the rules a balance obeys — it floors at zero rather than going into debt,
a null player never becomes a map entry, the map of sides cannot be written through, two arenas
do not share money, and ending a run takes its money with it. Verified by mutation on the three
that matter.

`ClientPayloadTest` was rewritten. It used to check that all four containment mechanisms were
wired to all three client channels; it now asserts the cause is absent — **no field of a location
may be keyed by player** — and that the four mechanisms have not come back.

The guard on the leaderboard ordering needed fixing before it worked. Its first version measured
a distance — "no live read within four hundred characters of the record write" — and the distance
was wrong, so it passed against a deliberately reintroduced bug. It now anchors on the expression
that computes the score. A guard that has not been shown to fail is not a guard.

### Save compatibility

An arena saved by an older build still carries `playerPoints` and `playerTeamMap`. They are read
by nothing and fall away the next time it is saved. That is deliberate: a balance in a file is
money from a run that ended, and handing it to whoever walks in next is the behaviour being
removed, not migrated.

## [0.8.8] - 2026-08-17 — the net, before the trapeze

A staged rebuild of the mod's structure starts here. The first stage builds nothing: it makes
the next ones safe to attempt.

### What the work needs to be safe

`Location` holds a hundred and twenty-six fields, and `LocationSerializer` writes them by hand
in two methods of two hundred lines that must be kept in step by remembering to. That has
already failed once — `scaleWithPlayers` and `adaptiveScalingEnabled` were added to the model,
wired into the editor, and never written to disk.

The stages ahead move every one of those fields into grouped settings objects. The danger is not
that the code stops compiling; it is that a field quietly stops being written and nobody notices
until somebody's arena comes back different after a restart. Servers have arenas saved right
now, under the current key names.

### A test that fills every field and watches all of them

`LocationRoundTripTest` builds a location, gives all 126 fields a value distinct from their
default — including the lists an arena is actually made of, its waves, spawn points, shop stock
and capture points, constructed reflectively — saves it, loads it, and reports anything that
came back different **by path**: `waves[0].pointsReward`, not "waves".

Reflection rather than a written-out list of assertions, because a hand-written list goes stale
exactly when fields are being moved around, which is precisely when it is needed.

Verified by deleting three real writes from three different nesting depths and confirming each
one is named. Two things it reported turned out to be the test's own fault and were fixed rather
than explained away: it was writing values past the clamps in setters (a radius of 1013 into a
field that stores at most 64), and comparing value objects that have no `equals` by identity.

### Registries in a plain unit test

The suite has been built around a rule that no test may construct a `Location` or an
`ItemStack`, because doing so threw and took previously green tests down with it. That was true
of the symptom and wrong about the cause: two copies of `fastutil` on an ad-hoc test classpath,
the older one first, and DataFixerUpper calling a method it did not have. Gradle only ever
resolved one version and never had the problem.

`McBootstrap.ensure()` brings the registries up. The one part that genuinely cannot work is the
tail of `Bootstrap.bootStrap()`, which validates translation keys against `en_us.json` from the
client assets — so that is swallowed specifically, and followed by a check that the registries
actually work, rather than a blanket catch that would hide a real failure behind a hundred
confusing ones.

This is why the round-trip test above could exist at all.

### Also found

A wave's `firedThisSession` flag — its record of having already fired a one-time trigger — is
correctly not saved, and is recorded as a deliberate exception. It is worth naming because it is
the same fault as the one at the top level, one floor down: an object describing how an arena is
built, holding state that belongs to the match being played.

## [0.8.7] - 2026-08-17 — the lobby used the wrong door

A lobby is meant to be the menu with a conversation in front of it: you agree on the map and the
sides, and then you walk in exactly as you would have walked in on your own. It was not that.

### Every lobby match on a PvP arena began wrong

An arena has two ways in, and they are not variations on each other. The PvE door teleports you
in and starts your session. The **PvP door** also assigns your side, puts you on the scoreboard
team that hides enemy nameplates, hands out that side's starting kit, and marks your player data
as being in a match.

The lobby called the PvE door for everything.

So every match started from a lobby began with players who had **no team**, **no scoreboard
entry**, **no starting items** and **no PvP flag** — and then the lobby wrote the team over the
top afterwards, which is why it mostly appeared to work. Mostly: the kit belonged to whichever
side the arena would have auto-balanced you onto, not the one you picked at the table, and the
scoreboard team came from the same wrong guess, so nameplates showed enemies as allies until the
next thing happened to correct them.

The lobby now asks the arena what it is and uses the door it has. The side chosen at the table
goes in as the spawn index, so the arena seats you right the first time instead of putting you
somewhere and being corrected — and `applyLobbySeating`, the method that existed only to do the
correcting, is deleted along with the reason for it.

Anything the lobby cannot resolve — nobody took a side, the side no longer exists, the chosen
spawn point was deleted since — falls back to the arena's own auto-balance, which is what a
walk-in gets.

### Every start position but the first was decorative

Chasing the door led to a second fault, older than the lobby and not caused by it.

An arena's spawn points are grouped by side, and the editor lets you place as many per side as
you like. Nothing ever used more than one of them. Auto-balance picked the emptiest side and then
returned **that side's first point** — so the third player on a side arrived standing inside the
first, and on an arena with wait-effects enabled they stood there blind and slowed, stacked on top
of each other, until the round started. The lobby made it worse by reading "anywhere on my side",
which is what nearly every player leaves the setting as, as "the first one".

Arrivals now spread across the side's positions in turn, by both doors. It wraps rather than
running off the end — six players on two positions is a crowded arena, not a crash — and it is
deterministic, so two players entering on the same tick cannot be handed the same answer.

The rule itself is four lines in `PvpOutcome`, testable without a server, and pinned by four
mutations including the one that puts the old behaviour back.

A smaller thing found in the same place: the side counts were built by merging every present
player's team name into the map, so a player carrying a team name from an arena that had since
been re-edited would **invent a side**. A phantom side has nobody on it, is therefore always the
emptiest, and would swallow every subsequent arrival. Only sides the arena actually has are
counted now.

### The entry result is read rather than assumed

`addPlayerToPvpLocation` returned `void` and refused silently in three places: a cooldown, a full
arena, a wave configuration that will not load. It returns `Outcome<Void>` now, like the PvE door
has since 0.8.1, and the lobby counts a player as having arrived only if they did. A match where
everyone was turned away at the gate no longer declares itself started — it stays open and says
so in the log.

### Guards

`LobbyLifecycleTest` now checks the arrangement rather than the workaround: that the lobby
branches on the kind of arena, that it passes the chosen side through, that `RUNNING` is claimed
only after somebody has actually been counted in, and that the after-the-fact seating patch has
not come back. A second rule for a decision that already has one is how two rules drift apart.

## [0.8.6] - 2026-08-17 — an audit of the last five releases

Five structural passes shipped quickly. This is the pass over them, and it found five faults —
four of them mine, two of them things I had called dead that were not.

### The shop's balance froze

`UpdatePointsPacket` was removed in 0.8.4 as dead: nothing constructed it, and the balance it
carried was a second copy of one the client already had. The **value** was dead. The
**notification** was not — its client handler was the only thing that called
`PlayerShopScreen.onPointsChanged()`, and the shop reads its balance only when told to.

So with the shop open, the figure on screen stopped moving and Buy stayed lit or dark against a
balance that had since changed. The refresh now hangs off `SyncPlayerDataPacket`, which is the
packet that actually carries the number.

### Every trigger evaluation did every trigger's work

Pulling the trigger conditions into `TriggerRules` in 0.8.3 had a trap I walked into: the switch
it replaced was **lazy**. Each branch read only what its own condition needed. Gathering all the
facts up front to hand to the pure function looks tidier and turned a cheap check into an
expensive one.

Concretely, an evaluation about a timer was scanning every player's inventory and armour, and
walking the location's whole wave list three times. That path runs once per trigger-enabled wave,
per active location, **per tick** — several hundred times a second on a busy server.

Facts are gathered on demand again. `HotPathTest` checks the guards are there and that the loop
is actually inside them, verified by a mutation.

### An activation counted players it had turned away

`ZoneActivationManager` announced "activated for N players" by counting everyone it tried to let
in, without reading whether entry succeeded — the same mistake the lobby made in 0.8.0 when it
declared a match started with everybody still at the gate. Entry still refuses for a re-entry
cooldown, a full arena or a broken config.

### Two leftovers of my own

The list of modes with records was plumbed all the way from the server in 0.8.5, and then its only
consumer was deleted along with the difficulty tabs. It marks the mode tabs that hold records now
— which is worth having for the same reason the whole release was: an empty board looked
identical to one nobody had played.

A refusal built with no reason vanished silently — in the vocabulary written to end silent
failures. It logs itself now.

### Also

- 271 tests.

## [0.8.5] - 2026-08-17 — one board, and the tier on the row

Reported as "the rating does not work at all". It did work; it was filed where nobody would look.

### Why the board looked empty

A record's key carried the difficulty the run was set on — `PvE_hard`, `PvE_nightmare`. The
screen opened on Normal. So an arena tuned to anything else showed an empty table under every
heading a player would think to open, with nothing to say the records were one tab away. From the
player's seat that is indistinguishable from a leaderboard that does not work.

### The tier belongs to the run, not to the board

There is one board per mode now, and each row says what it was played on. A Nightmare clear and
an Easy one appear in the same ranking, told apart by the badge beside the name. Normal is
unmarked — it is the baseline, and marking every ordinary row would make the exceptional ones
harder to spot rather than easier.

The difficulty tabs are gone from the screen.

**Existing history is kept.** Records filed under a tier key would have been orphaned the moment
nothing wrote or read that key — every ranking a server had accumulated would vanish on the
update. Loading folds them into the mode's board and gives each one the tier read from the key it
was filed under. A record written before the tier was kept has none, and stays unmarked rather
than being assigned a Normal it was never verified at.

### Profile updates at the end of a run

The lifetime panel's profile rides along with the player-data sync, throttled to one packet every
few seconds. The end of a run was supposed to send it unconditionally — the code says so in a
comment — but only login did. If the throttle had just fired, a player finishing a run and opening
their stats saw the numbers from a moment before it ended. It sends outright now, and wave
completions sync too, because finishing a wave moves the profile without necessarily awarding
anything.

### Also

- `LeaderboardTierTest` pins the tier surviving a save and load, an older record staying unmarked
  rather than acquiring a Normal, and the fold running on load — the last checked by a mutation,
  which caught a first version of the test that was only finding the method's own definition.
- 268 tests.

## [0.8.4] - 2026-08-16 — what a client never receives

First step of the largest structural change: separating an arena's design from what is happening
inside it. This one closes the far end of the 973-points bug.

### Live per-player state stops travelling

A player's points and their side are decided by the server and read by the server. **The client
reads neither from a location** — its balance comes from its own synced player data and its team
from the teammates packet. Both were being sent to every client, in every location, in every
sync, for nothing.

That was never only bandwidth. The editor held a copy of the whole location, points included, and
pressing Save sent it back — rolling every in-progress player's balance to whatever that copy
happened to contain. `LocationSection.RUNTIME` was added to make the server refuse those keys.
This closes the other end: what a client never receives, it can never send back.

`LocationSection.PER_PLAYER_RUNTIME` names the distinction, because it is not the same as
"runtime". A client needs to know an arena is locked — it greys out the way in. It has no use for
how many points everyone has.

### Three channels, three different strip lists

Locations reach clients three ways, and each stripped a different set: the single-location packet
removed the content lists, the join-time sync removed shops only, and the two request paths
removed **nothing at all** and sent the raw server save.

They now go through one function, `LocationManager.saveForClient()`. Having three was how a key
came to travel on one channel and not another, which is the same shape as every other duplicated
arithmetic this codebase has produced.

### A dead packet removed

`UpdatePointsPacket` was registered and **nothing ever constructed it**. Its client handler wrote
a balance into the client's own copy of a `Location`, which no screen reads. It has been deleted.
Packet ids come from registration order, so protocol 15 → 16.

### Also

- `ClientPayloadTest` checks that both keys are stripped, that the lock survives — stripping the
  whole RUNTIME section instead would have made every locked arena look open — that all four
  payload paths go through the one function, and that the dead packet has not come back.
- 261 tests.

## [0.8.3] - 2026-08-16 — the numbers an arena is tuned against

Same structural pass as 0.8.2, applied to the wave triggers. No behaviour changes.

### The thresholds come out of the switch

Every wave trigger's condition lived in one switch inside `TriggerEvaluator`, reading a session,
a player list and an item registry as it went. The numbers in it are what an arena builder tunes
against — five waves survived, a hundred mobs killed, five minutes elapsed, few mobs left — and
not one of them could be checked without starting a server and playing until the trigger either
fired or did not. Which is a slow way to find an off-by-one.

`TriggerRules` now decides, over a record of plain facts. `TriggerEvaluator` gathers those facts,
because that is the part that needs a world.

Seventeen tests, five mutations tried against them and all five caught: turning "past five waves"
into "at least five", turning "at least ten kills" into "more than ten", letting an emptied arena
count as running low on mobs, having a timer read a different timer's counter, and dropping the
grace window that lets a just-fired event still satisfy an AND condition.

### One quirk, now written down

**A wave of fewer than five mobs can never fire "few mobs remaining".** A fifth of four is zero in
integer arithmetic, and zero mobs left has already been answered as "the wave is over" rather than
"the wave is running low". This is existing behaviour and is unchanged — but it is the kind of
thing that reads from the editor as a broken trigger, so it is now pinned by a test that says so
in as many words rather than left to be rediscovered.

### Also

- Triggers owned by other systems — the four shop ones and the five PvP round events — are named
  in `DECIDED_ELSEWHERE` rather than falling through a default. An unhandled trigger is now a
  genuine gap, and a test walks every value the editor offers to check none is unaccounted for.
- 254 tests.

## [0.8.2] - 2026-08-16 — who won, and why

Second structural pass: a pure core with a thin seam, applied to the PvP round manager. No
behaviour changes — this is the same code, arranged so it can be examined.

### Four decisions come out of the tangle

`PvpRoundManager` is sixteen hundred lines in which four genuine decisions sat mixed up with
teleports, scoreboards, packets and messages. They are the decisions that end rounds and hand out
victories, and they were the least examinable code in the mod: checking any of them meant starting
a server and playing a match with deliberately lopsided teams or a deliberately drawn round —
which means in practice they were never checked at all.

`PvpOutcome` now answers them in plain values, with no Minecraft anywhere in it:

- **Who is ahead.** The single highest-scoring side, or nobody on a tie. A tie has no winner
  rather than an arbitrary one, because this is what decides a round when the clock runs out, and
  picking the first of two equal teams would hand somebody a victory on the order their spawn
  points happen to be listed in.
- **Who wins on a timeout.** Deathmatch counts kills, because that is its own scoreboard;
  everything else counts survivors, because the side still standing was winning the fight.
- **Who changes sides.** The first player of the largest team moves to the smallest, and only
  when the gap is at least two — an odd number of players makes a gap of one unavoidable, and a
  balancer chasing it would move somebody after every join and leave.
- **When the match is over.** Deathmatch ends on its kill target whatever the round count says;
  every other mode plays its rounds out.

### What this buys

Twenty tests over the rules that decide who wins, none of which needs a server. Four mutations
were tried against them and all four were caught: making a tie award victory to the first side,
balancing a gap of one, making deathmatch wait for its rounds, and letting a player with no side
count as a side of their own.

That last one is worth naming — a player with no team does occur, when somebody enters before the
seating runs or has their team cleared. Counting them under an empty name would invent a third
side that wins the round by having a survivor nobody can see.

The manager keeps everything that needs a world. It is the same split that made `SpawnCount` and
`Lobby` testable, applied to the code where being wrong is most visible: a player who deserved a
win and did not get one notices immediately.

## [0.8.1] - 2026-08-16 — saying no out loud

First of four structural passes from the architecture review. This one is the cheapest and hits
the thing that has cost the most: nearly every bug reported against this mod has had one shape —
something declined to do what it was asked, and said nothing.

### A refusal is now a type

`Refusal` carries a translation key and its arguments. `Outcome<T>` carries either what an
operation produced or why it did not. Both are free of Minecraft, so the code that decides
*whether* to refuse can be tested without a server — the split that already made `SpawnCount`
and `Lobby` testable.

A boolean was not enough: the caller then invents its own reason, which is how four screens ended
up with four wordings for the same list cap and two shop paths were reduced to measuring a list
before and after to work out what had happened. An exception was not right either — being told no
is ordinary, not exceptional.

`Refusals` is the catalogue: every reason the mod can give, named once, so a sentence is written
in one place rather than at each call site.

### Entering an arena reports whether it worked

The private entry path had six refusal branches and returned a boolean. The public
`addPlayerToLocation` threw that away and returned `void`.

**This was a live bug.** `LobbyService.begin()` counted a player as having arrived whenever the
call did not throw — so a lobby whose members were all bounced by a re-entry cooldown, a full
arena or a broken config still declared its match started, and (per 0.8.0's rules) held that arena
until a session ended that had never begun. The portal had the same fault in embryo: it read a
boolean the facade would not give it.

Both read the outcome now. No new translation keys were needed — all six reasons reuse the
sentences the entry path already had, so the same words reach the same players and the caller can
finally know what happened.

### Lists that fill up name their ceiling

The six capped adders returned `boolean` since 0.7.4 — the caller knew it had been refused but not
by what. They return an outcome carrying the limit that stopped them, and the four screens that
each worded it differently now show the same sentence.

Two shop paths stop inferring the refusal by comparing the list's size before and after, which is
what a caller does when the thing it called will not tell it anything.

### One catalogue, not two dialects

`LobbyManager` had its own `Refusal` enum and its own `Result` — they were the pattern everything
else was measured against. They now use the shared vocabulary, because keeping a second copy of an
idea is how two dialects start.

### Also

- `RefusalCatalogueTest` checks that every reason exists in all eight locales (a missing one is
  shown to the player as a raw key), that the refusable operations still return an outcome, and
  that no screen builds a cap message from a hand-typed key.
- Two translation keys added: the shop and team-spawn caps, which had been borrowing a key named
  after a TACZ bulk import.
- 217 tests.

## [0.8.0] - 2026-08-16 — the lobby

A table to sit at before a match: pick the map, pick the mode, pick your side and your start
position, see who else is in, and start when the group agrees. Warcraft III, Red Alert 3,
StarCraft II and Counter-Strike each solved a piece of this decades ago; what follows is which
piece came from where, and why.

### A lobby is its arena

There is at most one lobby per location, and it has no identifier of its own — it **is** the
location it sits on. That single decision removes id allocation, orphan collection and "which of
these three lobbies did you mean" from the whole design; "is this arena spoken for?" becomes a
map lookup. The one cost is that changing the map is a rekey rather than a field assignment, and
it is the only operation that can fail because the destination already has a table at it.

The host decides the map and the mode, as in Warcraft and Red Alert. That is as much a mechanical
choice as a familiar one: one writer means no votes to tally, no ties to break, and no window in
which two players change the map at once.

### Sides belong to the map

The slot grid is built from the arena's own spawn points, so the sides on offer are the sides the
admin actually built. Changing the map therefore clears everyone's seat — Warcraft does the same,
because carrying "Red" across to an arena that has never heard of it would seat that player
nowhere at all.

Start positions are numbered the Red Alert way: `1.1`, `2.3`, matching the markers on the map
preview, so the number in your row and the dot on the picture are the same thing. `#—` means
"anywhere on my side", which is what most players will leave it as.

### Ready is pressure, not a lock

Start needs the minimum player count and nothing else. Everyone pressing Ready is how a table
signals it has finished deliberating, but a match is not held hostage by one person who wandered
off: the countdown runs out and leaves without them. Uneven sides are allowed — three against one
is a decision a group is entitled to make — but everybody on one side is refused, because that is
not a match.

### The map preview the server has to draw

The tactical minimap reads terrain from the client's own world, which works because the player is
standing in the arena. A lobby is the exact opposite: everyone is somewhere else, the arena's
chunks are loaded on no client, and there is nothing there to read. Red Alert can show you the map
because the map is a file; here it is a piece of a live world, and only the server can see it.

So the server draws it — a few chunks per tick, because an arena two hundred blocks across covers
about a hundred and seventy of them and reading those in one tick is a freeze as long as the disk
takes. Cells are grouped by chunk when the job starts, which is what makes the budget mean
anything: a tick costs a fixed number of chunk loads rather than a fixed number of columns that
might each touch a different one. The result is a palette and one byte per cell, about sixteen
kilobytes, sent once and cached at both ends.

`ColumnColor` is now shared by the preview and the tactical minimap. Two copies of that arithmetic
would drift, and nobody would be able to say which was right.

### Closing the window is not leaving

A lobby is a screen, not a room, so a player may close it and walk around. Escape keeps the seat;
Leave gives it up. That would be a trap on its own — being teleported into an arena with no
warning — so the last five seconds of the countdown arrive as an on-screen message whether the
customizer is open or not. That part is Counter-Strike's, which announces its warmup out loud.

### Every other door shuts

An arena has several ways in: the hub's Join, a portal, an activation zone, a boundary that pulls
stragglers back. A lobby is worth nothing if somebody can stroll through one of the others and be
standing inside before the table has finished choosing sides. While a lobby is gathering, all of
them refuse and say where the way in now is; the portal goes further and simply is not there,
because a gate that stands rejecting people invites a queue. It returns by itself afterwards.

Admin routes stay open. An operator moving somebody is a decision, and often the one needed when a
lobby has gone wrong.

A match that came from a lobby also **skips the ready-check**. Freezing players at their spawns and
asking them to confirm is right for someone who walked in through a portal, and redundant for a
table that has just spent a countdown agreeing.

### Per-arena switches

Lobbies are **off by default**, so nothing about an existing arena changes until an admin opts it
in. Alongside that: whether a host may move the table to another map, and which modes may be
played here. An empty mode list means "the mode this arena is configured for" rather than "all of
them" — an admin who never opens this section gets the arena they built, not five modes they never
tested.

### Also

- Protocol 14 → 15. Six packets are appended: one op packet for every verb a customizer has,
  following `ShopItemOpPacket`, plus the lobby state, the list the hub shows, and the map preview
  with its request.
- Nothing the client sends is trusted. Host, permissions, whether the doors are shut — all checked
  against the server's own lobby, and a refusal comes back as a message rather than as silence.
- 51 translation keys across all eight locales.
- `LobbyStateTest`, `LobbyManagerTest`, `MapSnapshotWireTest`, `LobbyLifecycleTest` and
  `LobbyEntryGuardTest` — 40 tests over the state machine, the wire format, the lifecycle and the
  doors.

### Found by the audit before this shipped

Eight defects in the above, three of which broke the feature outright.

**A lobby outlived its match.** It stays in a running state while the game plays, because the hub
reads the host and the join policy from it — and nothing dropped it afterwards. The arena was
marked as spoken for from its first match until the server restarted. Disposal now happens in the
session teardown every finished run goes through, and a test reads that path.

**The customizer never updated.** It is entirely server-driven: somebody joining, taking a side or
pressing Ready arrives as a packet and nothing else. Without a rebuild on arrival the screen kept
the widgets it opened with, so a new player got no row and your own toggles looked dead. Every
button would have appeared broken while working perfectly.

**You could not get back into your own lobby.** Escape keeps the seat by design, but the hub's
button was greyed out — the server quite correctly reporting that you cannot join twice. A player
ended up in a lobby they could neither see nor close.

**Two map previews cancelled each other.** Two players on two arenas each ask about once a second,
and each request replaced the running job. Neither picture ever finished. They queue now.

**Seating was written in half.** The lobby set the team the match logic reads but not the
scoreboard team the nameplates read, so a player was logically on one side and visibly on the
other — seeing enemies as allies, and shot by their own.

**Auto-balance undid the table's decisions**, moving players between sides a group had deliberately
made uneven. It stands down for a lobby match.

**A failed start could strand an arena forever.** The running state was claimed before anybody had
entered, so a failure marked the arena as occupied by a match nobody was playing, with nothing to
clear it — the disposal happens when a session ends, and no session would have begun.

**The slot grid broke on a narrow window.** Fixed coordinates put the kick button off the screen
and slid the row under the map preview. The layout is computed from the right edge now, and a long
name is cut rather than drawn underneath the controls.

## [0.7.4] - 2026-08-15 — five ways to fail without saying so

A batch of reports that turned out to share one shape. In every case the mod did something
other than what the admin asked, and told nobody: no error, no log line, nothing on screen.
The fix in each case is as much about saying so as about the behaviour.

### The interface only moved one block

The HUD editor let you drag the wave counter. The other five blocks — status, teammates,
objectives, kill board, minimap — were drawn at coordinates written into the render code, so
there was nothing to drag and no way to ask.

Every block now has an anchor and an offset (`HudElement`, `HudAnchor`, `HudLayout`), and
`HudEditScreen` lets you pick any of them and move it. The defaults reproduce the old
hardcoded margins exactly, down to the pixel, so an existing setup opens looking identical.
Right-anchored blocks resolve against their *measured* width rather than a guess, which is
why they stay put when their contents change length.

### The minimap drew no terrain

The map showed players, points and the boundary over an empty background. There was no
terrain layer at all — nothing had been written to draw one.

`MinimapTerrain` reads the highest block in each column and colours it with that block's own
map colour, shaded by height so a wall reads as a wall. It samples 900 columns a frame and
keeps the result, so the picture fills in over about a second and then costs nothing.

Columns outside the client's render distance cannot be coloured — the client holds no block
data for them. Those are left as background and retried, so an arena larger than the render
distance paints itself in as you walk. That is a real limit of drawing this client-side, not
something to work around.

### Capture points were not saved

Reported as "does not save", and reading the code found no break: the list is not one of the
content-sized ones held back from the location packet, it is mapped to a section, and the
editor sends that section. Three readings, three dead ends. `CapturePointSaveTest` puts a
point through each hop instead of arguing about it, and each hop was clean.

The break was in the editor. It wrote changes into its own copy and only sent them if you
left through the Save button — a mutation followed by a close, an escape, or a jump into a
sub-screen was simply gone. It now persists on every change, with a 600 ms queue so dragging
a radius slider does not flood the server, and `saveAndClose` waits for the send rather than
racing it.

### Spawn points vanished at the limit

Six list adders silently returned when the list was full. Add a spawn point past the cap and
the button clicked, the screen redrew, and nothing was added — indistinguishable from the
point being added and then lost, which is what it was reported as.

All six now return a `boolean`, and every caller says which it was. Buttons go inactive at
the cap with a tooltip explaining why, so the wall is visible before you hit it.

### The number you typed was not the number that spawned

The one with the widest reach. A mob count in a wave was multiplied by three further
factors, none of which appeared anywhere in the editor:

```
spawned = (count + growth × (wave − 1))
          × players              1 … party size
          × adaptive difficulty  0.1 … 5.0, moves during play
          × difficulty preset    0.75 … 1.5
```

Three zombies could arrive as thirty-seven. Nothing on screen explained where the other
thirty-four came from, so the only available theory was that the count field did not work —
and re-typing the 3 confirmed it, over and over.

- The arithmetic now lives in one place, `SpawnCount`, which returns the count **and the
  factors that produced it**. The spawner and the editor's preview are the same code, so a
  preview cannot drift from the server.
- **Party scaling** and **adaptive difficulty** are switches per location, next to the
  difficulty preset, because they are the same sum. Both default to on: an arena built
  before this release keeps the difficulty it was tuned at.
- The wave mob list shows what each entry actually becomes — `Wave 3: 9 solo · 37 with
  four`. The adaptive factor is deliberately absent from those figures, since it depends on
  how the players are doing; the line is marked and the tooltip gives its range rather than
  inventing a number.
- The per-entry ceiling (120) now logs the full breakdown when it bites, instead of quietly
  trimming the wave.

### Also

- Both new location switches are serialized, sectioned and carried by the section diff.
  Absent in NBT means on, so loading an older arena does not change its difficulty.
- Nine translation keys across all eight locales.
- `SpawnCountTest` pins the formula, including the reported 3 → 37 case, so a fourth factor
  cannot be added without someone deciding whether the editor should show it.

### From the audit that followed

A pass over everything above, before starting the next feature. Seven defects, one of them
serious enough that it would have cost players their belongings.

**A player's inventory could be destroyed by a restart.** When someone dies, the backup
holding their real inventory moves out of `playerBackups` — which is persisted — and into
`pendingDeathRestores`, which was not written to disk at all. So the move did not merely
fail to protect them: the `saveRuntimeState()` meant to secure it wrote a file with the
backup gone from the only map being saved. A server stop before they clicked Respawn
destroyed everything they owned, permanently, with nothing in the log. Introduced in 0.7.3
along with the dead-player guards. Both maps are now written and read back, all three
hand-off sites persist the move, and `InventoryBackupPersistenceTest` reads the field
declarations rather than a fixed list — so a third such map cannot be added and left out,
which is exactly how the second one arrived.

**A location could claim waves it did not have.** The wave-count field accepted up to 9999
while the configured ceiling defaults to 100. The extra waves were dropped in silence and
the declared total was set to the typed figure anyway, so the arena advertised 9999 waves,
held 100, and replayed those hundred on a modulo. The count is now clamped to what was
actually created, and says so.

**Two more silent drops at a cap.** Adding a loot spawn, and adding a mob spawn point by
typing coordinates, both discarded the entry past the limit without a word — the second one
because only the "add here" button had been given the disabled-at-cap treatment. Both now
report it.

**Two rows of completion rewards were unreachable.** The screen fixed its page size at four
while the base class computes how many rows fit; four needed 365px of screen to be true, so
at GUI scale 4 the last rows sat entirely below the scroll band, drawn by no pass and with
their clicks filtered away. The duplicate arithmetic is gone, here and in the wave mob list.

**Escape lost the last capture-point change.** Every edit sends immediately unless the
600 ms cooldown is holding one back; leaving by Escape inside that window closed the screen
with the change still queued — the same loss the screen was fixed for, through the one exit
that did not go through Save.

**HUD blocks did not land where they were dropped.** The editor measured blocks by their
nominal size while the game anchors them by their real one, so a right-anchored block jumped
by the difference on release. The live HUD's own measurements are now what the editor uses.

**The minimap issued 23,000 draw calls a frame.** One per pixel, for a panel the size of a
stamp. Runs of equal colour are merged into single fills — the same picture, an order of
magnitude fewer calls.