survivorTeam) {
+ survivorTeam.remove(player); // raus aus dem Pool von updateViewer
+ player.removeTag(Tags.TEAM_ID);
+ player.setGameMode(GameMode.SPECTATOR);
+ player.setTag(Tags.TEAM_ID, TeamHelper.SPECTATOR_TEAM_ID);
+ player.updateViewableRule(_ -> false);
+ // kein removeTag(Tags.HIDDEN) - genau das ist Fix 7
+}
+```
+
+| Test | Erwartung | Heute |
+|---|---|---|
+| `testDeathLeavesHiddenTagBehind` | dokumentiert den eingefrorenen Tag | ✅ grün (Ist-Zustand) |
+| `testManualToggleDoesNotReachSpectator` | Spectator wird mit ausgetragen | 🔴 `expected false, was true` |
+| `testSpectatorSeesSlenderDuringAttack` | Spectator sieht den Angriffsmodus | 🔴 `expected true, was false` |
+
+Beide Spectator-Tests prüfen **vor** der eigentlichen Assertion einen lebenden Kontroll-Survivor.
+Der ist grün — was beweist, dass der Leak am Spectator-Status hängt und nicht am allgemeinen
+Sichtbarkeitsdefekt aus Fix 2.
+
+### Weitere fehlende Fälle
+
+Nach Priorität, wenn du die Abdeckung ausbauen willst:
+
+1. **Rundenstart:** nach `GameStartEvent` muss `slender.getViewers()` leer sein — auch in einer
+ zweiten Runde mit denselben Spielern (fängt Fix 5).
+2. **`SlenderReviveEvent`:** nach dem Revive muss der neue Slender eine Regel haben und für
+ Survivors unsichtbar sein (fängt Fix 4). Erweitere `SlenderReviveIntegrationTest.java:49`.
+3. **Instanzwechsel:** `setInstance` darf die Regel nicht verlieren — relevant wegen
+ `TeamHelper.teleportTeams` und `InstanceSwitchChunkPlayer`.
+4. **Später hinzukommender Spieler:** wer mitten in der Runde joint, darf einen unsichtbaren Slender
+ nicht sehen.
+5. **Tod des Slenders:** `PlayerDeathListener:52` feuert `SpectatorAddEvent` ohne Team-Prüfung —
+ `SpectatorService.join:60` würde die Slender-Regel mit `_ -> false` überschreiben (siehe
+ [Fix 7](#fix-7)).
+
+---
+
+## 6. Verifikation
+
+```bash
+./gradlew :game:test --tests '*Visibility*' --tests '*Slender*'
+```
+
+Checkliste:
+
+- [ ] `SlenderVisibilityIntegrationTest` grün, insbesondere `testAutoTimeoutUnregistersViewer`
+- [ ] `SlenderSpectatorVisibilityIntegrationTest` grün (Fix 7)
+- [ ] Die Vorbedingungs-Assertions sind noch da — sonst sind die Tests vacuously grün
+- [ ] `SlenderReviveIntegrationTest` weiterhin grün
+- [ ] `grep -rn "updateNewViewer\|updateOldViewer" game/src/main/` → keine Treffer mehr
+- [ ] `grep -rn "Tags.HIDDEN" game/src/main/` → keine Treffer mehr (oder klar benannte Restnutzung)
+- [ ] `Alt+F7` auf `SlenderVisibilityChangeEvent` → gefeuert aus **allen** State-Übergängen:
+ `changeStatus()` ×3, Timeout, READY-Übergang, Rundenstart, Revive
+- [ ] `grep -rn "updateViewableRule" game/src/main/` → jeder Treffer gehört zu genau einem der drei
+ Besitzer aus [Abschnitt 2b](#2b-das-spectator-system-seit-270); keiner überschreibt einen anderen
+
+Manuell im Spiel — das ist die Sequenz, die den Bug erzeugt hat:
+
+1. Runde starten, als Slender das Auge **einmal** drücken → sichtbar
+2. **Nicht** erneut drücken, die ~16 s auslaufen lassen → Slender verschwindet
+3. Als Survivor **über 96 Blöcke** weglaufen und zurückkommen → Slender muss **unsichtbar bleiben**
+4. Auge erneut drücken → Slender muss **sichtbar** werden (nicht umgekehrt — das prüft die Polarität)
+5. Runde zu Ende spielen, neue Runde mit anderem Slender → zu Rundenbeginn unsichtbar
+
+Und die Spectator-Sequenz für Fix 7 — beide Richtungen, sie schlagen unterschiedlich fehl:
+
+6. Auge drücken (Slender **sichtbar**), dann einen Survivor töten → als Spectator zuschauen:
+ der Slender darf nach dem Zurückschalten **nicht** weiter sichtbar bleiben
+7. Umgekehrt: einen Survivor töten, während der Slender **unsichtbar** ist → als Spectator muss man
+ den Slender beim nächsten Augendruck **sehen** können
+
+---
+
+## Zusammenhang mit dem Sprint-Guide
+
+Vier Befunde teilen sich dieselben Zeilen mit dem [Sprint-Guide](sprint-stamina-fix-guide.md):
+
+| Gemeinsame Wurzel | Sprint-Seite | Sichtbarkeits-Seite |
+|---|---|---|
+| `SlenderBar.java:64-72` umgeht den Trigger | Effekte/Speed ohne Trigger-Pfad | Fix 2 — **dein Hauptsymptom** |
+| `Tags.HIDDEN` relativ getoggelt | — | Fix 1, Polaritätsumkehr |
+| `StaminaBar.stop()` ohne `onStop()` | Effekte/Attribute überleben Cleanup | Fix 5 — Regel und Tags überleben |
+| `SlenderReviveListener` unvollständig | keine saubere Bar-Übergabe | Fix 4 — neuer Slender sichtbar |
+
+**Wenn du beide Guides abarbeitest, bau `onStop()` (Fix 5 hier / B4 dort) nur einmal** — es ist
+dieselbe Methode. Gleiches gilt für den toten Guard in `SlenderBar.java:88`.
diff --git a/docs/sprint-stamina-fix-guide.md b/docs/sprint-stamina-fix-guide.md
new file mode 100644
index 00000000..bae1cce9
--- /dev/null
+++ b/docs/sprint-stamina-fix-guide.md
@@ -0,0 +1,931 @@
+# Fix-Guide: FoodBar & Sprinten (Survivor-Seite)
+
+> **Scope:** Nur der Survivor-Sprint-Pfad — `FoodBar`, `PlayerStartSprintingListener`,
+> `CygnusPlayer.setSprinting`, `CygnusEntityActionListener`.
+> Der Slender-Pfad (`SlenderBar`, `SlenderReviveListener`) hat eigene Befunde und ist
+> **bewusst nicht** Teil dieses Guides — siehe Abschnitt [Bewusst ausgeklammert](#bewusst-ausgeklammert).
+
+## Wie du dieses Dokument benutzt
+
+Jeder Fix hat eine **Sprungmarke**. In IntelliJ:
+
+| Was | Shortcut | Eingabe |
+|---|---|---|
+| Klasse öffnen | `Ctrl+N` (`Cmd+O`) | `FoodBar` |
+| Datei öffnen | `Ctrl+Shift+N` | `FoodBar.java` |
+| Zu Zeile springen | `Ctrl+G` (`Cmd+L`) | `78` |
+| Alle Aufrufer finden | `Alt+F7` auf dem Symbol | — |
+| Zurück zum Ausgangspunkt | `Ctrl+Alt+←` | — |
+
+Empfohlener Ablauf pro Fix: **Test zuerst schreiben** (Abschnitt [Tests](#tests)) → Test läuft rot →
+Fix anwenden → Test läuft grün. Die Tests sind so gebaut, dass sie den Bug *vorher* wirklich fangen;
+bei jedem Test steht dabei, ob er vor dem Fix rot ist (🔴) oder reine Absicherung (🛡️).
+
+---
+
+## 1. Das Gesamtbild
+
+Der Sprint-Zustand eines Survivors lebt an **vier** Orten gleichzeitig. Das ist die Wurzel fast aller
+Symptome: niemand besitzt den Zustand, jeder schreibt darauf.
+
+```mermaid
+flowchart TB
+ subgraph client["Client (autoritativ für Sprint-Input)"]
+ C["Spieler drückt Sprint-Taste"]
+ end
+
+ subgraph server["Server"]
+ PKT["CygnusEntityActionListener
ClientEntityActionPacket"]
+ EV["PlayerStartSprintingEvent"]
+ LIS["PlayerStartSprintingListener"]
+ FB["FoodBar
state + currentSpeedCount"]
+ CP["CygnusPlayer
blockedSprinting"]
+ ATTR["MOVEMENT_SPEED
AttributeModifier"]
+ META["entityMeta.setSprinting"]
+ XP["player.setExp
(die sichtbare Leiste)"]
+ end
+
+ C -->|"START_SPRINTING"| PKT
+ PKT --> EV --> LIS
+ LIS -->|"canConsume()"| FB
+ LIS -->|"liest"| CP
+ PKT -->|"setSprinting()"| CP
+ CP --> ATTR
+ CP --> META
+ FB -->|"Tick 1000ms"| XP
+ FB -->|"schreibt"| CP
+
+ style FB fill:#ffe0e0,stroke:#c00
+ style CP fill:#ffe0e0,stroke:#c00
+```
+
+**Rot markiert = geteilter Zustand ohne Besitzer.** `blockedSprinting` wird von der `FoodBar`
+geschrieben und vom Listener gelesen; `FoodBar.state` wird vom Listener geschrieben (über
+`canConsume()`!) und von der Bar gelesen. Beide Richtungen — daher die Regressionen.
+
+### Der Zustandsautomat der FoodBar
+
+**Ist-Zustand** (`FoodBar.java`):
+
+```mermaid
+stateDiagram-v2
+ [*] --> READY: Konstruktor
currentSpeedCount = 20
+
+ READY --> DRAINING: canConsume()
⚠️ mutiert beim Fragen
+
+ DRAINING --> DRAINING: consume() alle 1000ms
count -= 2
+ DRAINING --> REGENERATING: count <= 0
setBlockedSprinting(true)
+ DRAINING --> REGENERATING: switchToRegenerating()
(Sprint losgelassen)
+
+ REGENERATING --> REGENERATING: consume() alle 1000ms
count += 1
+ REGENERATING --> DRAINING: canConsume() && count > 7
⚠️ auch wenn Sprint abgelehnt wurde
+ REGENERATING --> READY: count == 20
setBlockedSprinting(false)
+
+ note right of REGENERATING
+ ⚠️ Der Rücksprung nach DRAINING
+ passiert auch, wenn das Event
+ bereits gecancelt wurde.
+ Das ist Bug #1.
+ end note
+```
+
+Die beiden ⚠️-Kanten sind das Problem: **`canConsume()` heißt wie eine Frage, ist aber ein
+Zustandswechsel.** Der Aufrufer kann nicht folgenlos „nur mal nachsehen".
+
+---
+
+## 2. Der Hauptbug im Ablauf
+
+Das ist die Sequenz hinter „die Stamina resettet nicht sauber" und „ich bin ewig gesperrt":
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant P as Spieler (Client)
+ participant L as PlayerStartSprintingListener
+ participant CP as CygnusPlayer
+ participant FB as FoodBar
+
+ Note over FB: Zustand: REGENERATING
count = 8, blockedSprinting = true
+
+ P->>L: START_SPRINTING (Taste gehalten)
+ L->>CP: hasBlockedSprinting()
+ CP-->>L: true
+ L->>L: event.setCancelled(true)
+ Note over L: ⛔ hier fehlt das return
+
+ L->>FB: canConsume()
+ Note over FB: REGENERATING && 8 > 7
→ state = DRAINING ✏️
+ FB-->>L: true
+ Note over L: kein setCancelled mehr —
aber Event ist schon gecancelt
+
+ Note over FB: Bar tickt jetzt als DRAINING:
count 8 → 6 → 4 → 2 → 0
+
+ FB->>CP: setBlockedSprinting(true)
+ Note over CP,FB: Der Spieler war die ganze Zeit
gesperrt und hat nie sprinten können —
trotzdem ist die Bar leer und
die 20s-Sperre beginnt von vorn.
+```
+
+**Warum du das im Spiel als „kaputt" erlebst:** Du wartest auf Regeneration, hältst die Sprint-Taste
+gedrückt (was jeder tut, wenn man wegrennen will), und die Bar fällt wieder auf null — ohne dass du
+jemals schneller geworden bist. Gefühlt: „die Stamina resettet nicht" oder „ich bin dauerhaft gesperrt".
+
+**Wichtige Einschränkung — damit du beim Testen nicht in die Irre läufst:** Kurzes *Antippen* der
+Taste heilt sich selbst. Das folgende `STOP_SPRINTING`-Packet läuft über
+`PlayerStopSprintingListener` → `switchToRegenerating()` und macht den Übergang rückgängig. Der
+Schaden entsteht nur beim **gehaltenen** Sprint-Input. Es ist also kein harter Deadlock, sondern ein
+reproduzierbarer Frust-Loop — deshalb ist der Bug bisher durch alle manuellen Tests gerutscht.
+
+---
+
+## 3. Die Fixes
+
+Reihenfolge ist beabsichtigt: Fix 1 ist der eigentliche Bug, Fix 2–3 verhindern die nächste
+Regression an derselben Stelle, Fix 4–5 sind Härtung.
+
+| # | Ort | Was | Aufwand |
+|---|---|---|---|
+| [1](#fix-1) | `FoodBar` + `PlayerStartSprintingListener` | Query/Command trennen, `return` ergänzen | mittel |
+| [2](#fix-2) | `FoodBar.handleFoodRegeneration` | Grenzwert + Clamping + Off-by-one | klein |
+| [3](#fix-3) | `CygnusPlayer.setSprinting` | Attribut-Key-Kollision, Modifier im gesperrten Zweig | mittel |
+| [4](#fix-4) | `StaminaService.getFoodBar` | Null-Sicherheit | klein |
+| [5](#fix-5) | `CygnusEntityActionListener` | Entprellung | klein |
+
+---
+
+### Fix 1 — `canConsume()` ist ein Mutator und läuft nach dem Cancel weiter {#fix-1}
+
+**Springe zu:** `PlayerStartSprintingListener.java:29-36` und `FoodBar.java:78-89`
+
+#### Was aktuell dasteht
+
+`PlayerStartSprintingListener.java:29`
+```java
+if (cygnusPlayer.hasBlockedSprinting()) {
+ event.setCancelled(true);
+}
+
+FoodBar staminaBarRef = staminaFunction.apply(player);
+if (!staminaBarRef.canConsume()) {
+ event.setCancelled(true);
+}
+```
+
+`FoodBar.java:78`
+```java
+public boolean canConsume() {
+ if (state == State.READY) {
+ state = State.DRAINING; // ← Seiteneffekt in einer "can"-Methode
+ return true;
+ }
+ if (state == State.REGENERATING && currentSpeedCount > 7D) {
+ state = State.DRAINING; // ← und hier nochmal
+ return true;
+ }
+ return false;
+}
+```
+
+#### Warum das falsch ist
+
+Zwei getrennte Fehler, die sich gegenseitig verstärken:
+
+1. **Fehlendes `return`.** Nach `setCancelled(true)` läuft die Methode weiter. Der Sprint ist bereits
+ abgelehnt, aber die Zustandsmaschine wird trotzdem noch angefasst.
+2. **`canConsume()` verletzt Command-Query-Separation.** Eine Methode, deren Name eine Frage stellt,
+ muss beantwortbar sein, ohne die Welt zu verändern. Sonst ist jeder Aufrufer verpflichtet, den
+ Nebeneffekt zu kennen — und genau das geht beim nächsten Refactoring verloren.
+
+Nur *einer* der beiden Fehler zu beheben reicht nicht: Mit `return`, aber weiterhin mutierendem
+`canConsume()`, ist der nächste Aufrufer wieder in derselben Falle. Ohne `return`, aber mit sauberer
+Query, würde die Ablehnung schweigend ignoriert.
+
+#### Der Fix
+
+**Schritt 1** — `FoodBar.java:78`, `canConsume()` in Frage + Kommando aufteilen:
+
+```java
+/** Schwelle, ab der ein Wiedereinstieg ins Sprinten während der Regeneration erlaubt ist. */
+private static final float REGEN_RESUME_THRESHOLD = 7.0F;
+
+/**
+ * Prüft, ob der Spieler das Sprinten beginnen darf.
+ *
+ * Diese Methode ist frei von Seiteneffekten und darf beliebig oft aufgerufen werden.
+ * Der eigentliche Zustandswechsel erfolgt über {@link #startDraining()}.
+ *
+ * @return {@code true}, wenn Sprinten erlaubt ist, sonst {@code false}
+ */
+public boolean canConsume() {
+ return state == State.READY
+ || (state == State.REGENERATING && currentSpeedCount > REGEN_RESUME_THRESHOLD);
+}
+
+/**
+ * Wechselt in den Verbrauchsmodus. Nur aufrufen, wenn der Sprint tatsächlich startet.
+ */
+public void startDraining() {
+ if (!canConsume()) return;
+ this.state = State.DRAINING;
+}
+```
+
+**Schritt 2** — `PlayerStartSprintingListener.java:29`, den ganzen Block ersetzen:
+
+```java
+FoodBar foodBar = staminaFunction.apply(player);
+if (foodBar == null) return; // siehe Fix 4
+
+if (cygnusPlayer.hasBlockedSprinting() || !foodBar.canConsume()) {
+ event.setCancelled(true);
+ return; // ← der eigentliche Fix
+}
+
+foodBar.startDraining();
+```
+
+Die Umstellung auf `||` ist kein Stil-Detail: sie macht strukturell unmöglich, dass zwischen
+„abgelehnt" und „Zustand ändern" noch Code steht. Der Zustand wird jetzt **nur** auf dem Pfad
+angefasst, auf dem der Sprint tatsächlich beginnt.
+
+#### Gegenprobe nach dem Fix
+`Alt+F7` auf `startDraining()` — es darf **genau einen** Aufrufer geben, und der muss hinter dem
+`return` liegen.
+
+---
+
+### Fix 2 — Regeneration: Off-by-one, Float-Gleichheit, fehlendes Clamping {#fix-2}
+
+**Springe zu:** `FoodBar.java:57-68`
+
+#### Was aktuell dasteht
+
+```java
+private void handleFoodRegeneration() {
+ if (this.currentSpeedCount == MAX_FOOD) { // Float == int
+ state = State.READY;
+ player.setBlockedSprinting(false);
+ return;
+ }
+
+ if (this.currentSpeedCount < MAX_FOOD) {
+ ++this.currentSpeedCount;
+ player.setExp(normalize(this.currentSpeedCount));
+ }
+}
+```
+
+#### Warum das falsch ist
+
+Drei Probleme, alle in vier Zeilen:
+
+1. **Off-by-one.** Geprüft wird *vor* dem Hochzählen. Der Tick, der `currentSpeedCount` auf 20
+ bringt, entsperrt noch nicht — erst der nächste. Die Erholung dauert **21 statt 20 Sekunden**,
+ und in dieser einen Sekunde steht die Leiste sichtbar voll, während du noch gesperrt bist. Das
+ ist der Teil, der sich im Spiel wie „resettet nicht sauber" anfühlt.
+2. **Float-Gleichheit als einzige Ausstiegsbedingung.** `currentSpeedCount` ist ein `float`. Heute
+ sind alle Schritte ganzzahlig (−2 / +1), also trifft `== 20` zufällig. Ändert jemand `FOOD_TAKE`
+ auf `2.5` oder die Regenerationsrate auf `0.5`, wird die Bedingung nie wahr → **permanenter
+ Sprint-Lock**, ohne dass irgendwo ein Fehler auftaucht. Das ist eine gestellte Falle für den
+ nächsten Balancing-Commit.
+3. **Kein Clamping.** Nichts hindert `currentSpeedCount` daran, über `MAX_FOOD` hinauszulaufen,
+ wenn ein zusätzlicher Regenerationspfad dazukommt.
+
+#### Der Fix
+
+```java
+private void handleFoodRegeneration() {
+ if (this.currentSpeedCount < MAX_FOOD) {
+ this.currentSpeedCount = Math.min(this.currentSpeedCount + FOOD_REGEN, MAX_FOOD);
+ this.player.setExp(normalize(this.currentSpeedCount));
+ }
+
+ if (this.currentSpeedCount >= MAX_FOOD) {
+ this.state = State.READY;
+ this.player.setBlockedSprinting(false);
+ }
+}
+```
+
+Dazu oben bei den Konstanten (`FoodBar.java:11`) die bisher implizite `++` benennen:
+
+```java
+private static final float FOOD_REGEN = 1.0F;
+```
+
+Erst zählen, dann prüfen — damit entsperrt derselbe Tick, der die Leiste vollmacht. `>=` statt `==`
+macht den Ausstieg unabhängig von der Schrittweite, `Math.min` deckelt sauber.
+
+> **Sonderfall, den du beim Testen sehen wirst:** `switchToRegenerating()` (`FoodBar.java:95`) setzt
+> auch aus `READY` heraus auf `REGENERATING` — jedes Loslassen der Sprint-Taste bei voller Bar tut
+> das. Mit obigem Fix heilt sich das im selben Tick (`count >= MAX_FOOD` → sofort zurück auf
+> `READY`). Vorher brauchte es dafür einen extra Tick. Kein eigener Fix nötig, aber gut zu wissen,
+> damit du den Zustandswechsel im Debugger nicht für einen Bug hältst.
+
+---
+
+### Fix 3 — Attribut-Key kollidiert mit Minestom, Modifier bleibt im gesperrten Zweig kleben {#fix-3}
+
+**Springe zu:** `CygnusPlayer.java:20-24` und `CygnusPlayer.java:55-72`
+
+#### Was aktuell dasteht
+
+```java
+private static final AttributeModifier SPEED_MODIFIER_SPRINTING =
+ new AttributeModifier(Key.key("minecraft:sprinting"), 0.25, AttributeOperation.ADD_MULTIPLIED_TOTAL);
+
+private static final AttributeModifier DISABLED_SPRINT_MODIFIER =
+ new AttributeModifier(Key.key("minecraft:sprinting"), 0.0, AttributeOperation.ADD_MULTIPLIED_TOTAL);
+
+@Override
+public void setSprinting(boolean sprinting) {
+ if (blockedSprinting) {
+ this.entityMeta.setSprinting(false);
+ this.sendSpringPackets();
+ return; // ← Attribut wird hier NICHT angefasst
+ }
+ // ...
+}
+```
+
+#### Warum das falsch ist
+
+**Problem A — Key-Kollision mit Minestom.** Verifiziert in
+`minestom-ce-extensions-1.2.0-sources.jar`:
+
+```java
+// LivingEntity.java:52
+private static final AttributeModifier SPRINTING_SPEED_MODIFIER =
+ new AttributeModifier(Key.key("sprinting"), 0.3, AttributeOperation.ADD_MULTIPLIED_TOTAL);
+
+// LivingEntity.java:58
+public static final Set PROTECTED_MODIFIERS = Set.of(SPRINTING_SPEED_MODIFIER.id());
+
+// AttributeInstance.java:119
+public void clearModifiers() {
+ this.modifiers.values().removeIf(modifier -> !LivingEntity.PROTECTED_MODIFIERS.contains(modifier.id()));
+}
+```
+
+`Key.key("sprinting")` löst zum Default-Namespace auf und ist damit **identisch** mit Cygnus'
+`Key.key("minecraft:sprinting")`. Folge: Cygnus' Modifier steht in Minestoms `PROTECTED_MODIFIERS`
+und **überlebt jedes `clearModifiers()`**. Ein Reset-Pfad, der aufräumen will, lässt ausgerechnet den
+Sprint-Modifier stehen.
+
+**Problem B — der gesperrte Zweig korrigiert das Attribut nicht.** Wer im Moment des Sperrens einen
+Modifier-Zustand hat, behält ihn. Heute rettet nur die Aufrufreihenfolge in `FoodBar.java:47-48`
+(`setSprinting(false)` *vor* `setBlockedSprinting(true)`), dass das nicht auffällt. Jeder andere
+Pfad, der `setBlockedSprinting(true)` setzt, während der Spieler sprintet, lässt den +25%-Boost
+aktiv — der Spieler ist „gesperrt" und rennt trotzdem.
+
+**Problem C — `DISABLED_SPRINT_MODIFIER` ist ein No-Op.** `ADD_MULTIPLIED_TOTAL` mit `0.0`
+multipliziert mit 1.0, ändert also nichts. Er existiert nur, um über die Key-Kollision den anderen
+Modifier zu verdrängen — ein Trick, der die Kollision aus Problem A voraussetzt.
+
+#### Der Fix
+
+```java
+private static final AttributeModifier SPEED_MODIFIER_SPRINTING =
+ new AttributeModifier(Key.key("cygnus:sprinting"), 0.25, AttributeOperation.ADD_MULTIPLIED_TOTAL);
+// DISABLED_SPRINT_MODIFIER ersatzlos löschen — er war ein No-Op.
+
+@Override
+public void setSprinting(boolean sprinting) {
+ boolean effective = sprinting && !this.blockedSprinting;
+
+ if (effective) {
+ this.getAttribute(Attribute.MOVEMENT_SPEED).addModifier(SPEED_MODIFIER_SPRINTING);
+ } else {
+ this.getAttribute(Attribute.MOVEMENT_SPEED).removeModifier(SPEED_MODIFIER_SPRINTING);
+ }
+
+ this.entityMeta.setSprinting(effective);
+ this.sendSpringPackets();
+}
+```
+
+Der frühe `return` verschwindet: `blockedSprinting` wird jetzt zu einem *Filter auf den Wert*, nicht
+zu einem Sprung an der Aufräumarbeit vorbei. Damit ist der Modifier auf **jedem** Pfad konsistent —
+die Reihenfolgeabhängigkeit aus Problem B ist strukturell weg.
+
+> ⚠️ **Nebenwirkung, die du kennen musst:** Mit eigenem Key überschreibt Cygnus' Modifier Minestoms
+> 0.3-Modifier nicht mehr. Das ist hier unkritisch, weil `CygnusPlayer.setSprinting` **kein**
+> `super.setSprinting()` aufruft und Minestoms Modifier damit nie gesetzt wird (siehe
+> `LivingEntity.java:111-119`). Solltest du später `super.setSprinting()` ergänzen, addieren sich
+> beide Modifier (0.3 + 0.25) statt sich zu ersetzen. Behalte das im Kopf — der Test in
+> [`CygnusPlayerSprintIntegrationTest`](#test-3) fängt genau diesen Fall ab.
+
+---
+
+### Fix 4 — `getFoodBar()` kann `null` liefern {#fix-4}
+
+**Springe zu:** `StaminaService.java:118` und `PlayerStartSprintingListener.java:33`
+
+```java
+public FoodBar getFoodBar(Player player) {
+ return (FoodBar) this.staminaBars.get(player.getUuid());
+}
+```
+
+`HashMap.get` liefert `null`, wenn kein Eintrag existiert — die Methode ist aber nicht als
+`@Nullable` markiert, und beide Sprint-Listener dereferenzieren das Ergebnis ungeprüft. Erreichbar
+ist das real: `Cygnus.finishGame()` (`Cygnus.java:194-200`) ruft `staminaService.cleanUp()` **vor**
+dem Rückbau des Packet-Listeners. Wer in diesem Fenster die Sprint-Taste drückt, bekommt eine NPE.
+
+**Fix:**
+```java
+public @Nullable FoodBar getFoodBar(Player player) {
+ return (FoodBar) this.staminaBars.get(player.getUuid());
+}
+```
+Plus `if (foodBar == null) return;` in beiden Listenern (in Fix 1 schon enthalten) und in
+`PlayerStopSprintingListener.java:26`.
+
+Wenn du an der Ursache statt am Symptom ansetzen willst: in `Cygnus.finishGame()` die beiden Zeilen
+tauschen, sodass der Packet-Listener zurückgebaut wird, *bevor* `cleanUp()` läuft. Beides zu machen
+ist hier richtig — Reihenfolge fixt diesen Fall, Null-Check schützt vor dem nächsten.
+
+---
+
+### Fix 5 — Keine Entprellung im Packet-Listener {#fix-5}
+
+**Springe zu:** `CygnusEntityActionListener.java:18-32`
+
+Cygnus ersetzt Minestoms Default-Listener (`Cygnus.java:170`), übernimmt aber dessen Entprellung
+nicht. Minestom prüft `oldState != sprinting` bevor es dispatcht; Cygnus feuert bei **jedem**
+`START_SPRINTING`-Packet ein neues Event. Zusammen mit Fix 1 war das der Verstärker — jedes
+wiederholte Packet zog erneut am Zustandsautomaten.
+
+Ergänze in `CygnusEntityActionListener.listener`:
+
+```java
+case START_SPRINTING -> {
+ if (player.isSprinting()) return; // schon im Zustand → nichts zu tun
+ // ... bestehender Code
+}
+case STOP_SPRINTING -> {
+ if (!player.isSprinting()) return;
+ // ... bestehender Code
+}
+```
+
+Verwandt, aber separat: `CygnusPlayerTickListener.java:26-29` sendet, solange gesperrt, **jeden
+Tick** ein volles `EntityAttributesPacket` + Metadata — 20 Pakete/s pro gesperrtem Spieler. Für sich
+harmlos, aber unnötig; sende nur bei tatsächlicher Änderung.
+
+---
+
+## 4. Tests {#tests}
+
+Das ist der Teil, der die Regressionen künftig verhindert. Drei Dateien, aufsteigend im Umfang.
+
+### Warum diese Tests so aussehen
+
+Vier Prinzipien, an denen sich die bestehenden Tests messen lassen müssen:
+
+1. **Verhalten testen, nicht Konstruktion.** `StaminaFactoryTest` und `StaminaServiceTest` prüfen
+ heute nur, dass Objekte entstehen und Getter etwas zurückgeben. Kein einziger Test hat je eine
+ `StaminaBar` getickt — deshalb konnte der tote Guard in `SlenderBar.java:88` seit dem
+ Initial-Commit überleben.
+2. **Den Task umgehen, `consume()` direkt aufrufen.** `start()` hängt die Bar an den echten
+ Scheduler — ein Test, der auf echte Sekunden wartet, ist langsam und flaky. `consume()` ist
+ `public`; ruf es in einer Schleife auf. Damit ist jeder Tick deterministisch und der Test läuft
+ in Millisekunden.
+3. **Invarianten prüfen, nicht nur Beispiele.** Der wertvollste Test unten ist nicht „nach 10 Ticks
+ ist die Bar leer", sondern „`blockedSprinting == true` **genau dann wenn** die Bar im gesperrten
+ Zustand ist". Ein Beispieltest fängt einen Bug, ein Invariantentest fängt eine ganze Bug-Klasse.
+4. **Den Bug erst rot sehen.** Schreib den Test, lass ihn gegen den *unveränderten* Code laufen und
+ überzeug dich, dass er fehlschlägt. Ein Test, den du nie rot gesehen hast, könnte auch einfach
+ nichts prüfen.
+
+Zum Zugriff auf `state`: Das Feld ist `protected` in `StaminaBar` und liegt im Package
+`net.onelitefeather.cygnus.stamina`. Tests im **selben Package** kommen ohne Getter und ohne
+Reflection dran. `currentSpeedCount` ist `private` — bewusst nicht angefasst: der Test soll den Wert
+über echte `consume()`-Aufrufe erreichen, nicht hineinschreiben. Das hält ihn ehrlich.
+
+---
+
+### Test 1 — Zustandsautomat der FoodBar
+
+`game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarIntegrationTest.java`
+
+```java
+package net.onelitefeather.cygnus.stamina;
+
+import net.minestom.server.instance.Instance;
+import net.minestom.testing.Env;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import net.onelitefeather.cygnus.player.CygnusPlayer;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests the state machine of the {@link FoodBar}.
+ *
+ * The tests call {@link FoodBar#consume()} directly instead of starting the scheduler task,
+ * which makes every tick deterministic and keeps the suite fast.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.4.0
+ */
+class FoodBarIntegrationTest extends CygnusPlayerTestBase {
+
+ /** Ticks needed to drain a full bar: 20 food / 2 per tick. */
+ private static final int TICKS_TO_DRAIN = 10;
+ /** Ticks needed to regenerate from empty: 20 food / 1 per tick. */
+ private static final int TICKS_TO_REGENERATE = 20;
+
+ @Test
+ @DisplayName("Eine frische Bar ist READY und nicht gesperrt")
+ void testInitialState(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance);
+ FoodBar foodBar = new FoodBar(player);
+
+ assertEquals(StaminaBar.State.READY, foodBar.state);
+ assertFalse(player.hasBlockedSprinting());
+ assertTrue(foodBar.canConsume(), "Eine volle Bar muss Sprinten erlauben");
+
+ env.destroyInstance(instance, true);
+ }
+
+ /**
+ * 🔴 Rot vor Fix 1: {@code canConsume()} wechselt heute beim blossen Fragen nach DRAINING.
+ *
+ * Das ist der Kern von Command-Query-Separation: eine Frage darf man beliebig oft
+ * stellen, ohne dass sich etwas aendert.
+ */
+ @Test
+ @DisplayName("canConsume() ist frei von Seiteneffekten")
+ void testCanConsumeDoesNotMutate(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance);
+ FoodBar foodBar = new FoodBar(player);
+
+ // Dreimal fragen darf den Zustand nicht bewegen.
+ assertTrue(foodBar.canConsume());
+ assertTrue(foodBar.canConsume());
+ assertTrue(foodBar.canConsume());
+
+ assertEquals(StaminaBar.State.READY, foodBar.state,
+ "canConsume() darf den Zustand nicht veraendern - dafuer ist startDraining() da");
+
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ @DisplayName("Voller Zyklus: READY -> DRAINING -> REGENERATING -> READY")
+ void testFullCycle(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance);
+ FoodBar foodBar = new FoodBar(player);
+
+ foodBar.startDraining();
+ assertEquals(StaminaBar.State.DRAINING, foodBar.state);
+
+ // Leerlaufen lassen.
+ for (int i = 0; i < TICKS_TO_DRAIN; i++) {
+ foodBar.consume();
+ }
+
+ assertEquals(StaminaBar.State.REGENERATING, foodBar.state,
+ "Bei leerer Bar muss die Regeneration beginnen");
+ assertTrue(player.hasBlockedSprinting(),
+ "Bei leerer Bar muss der Sprint gesperrt sein");
+ assertFalse(foodBar.canConsume(),
+ "Direkt nach dem Leerlaufen darf nicht wieder gesprintet werden");
+
+ // 🔴 Rot vor Fix 2: heute braucht es 21 Ticks, weil vor dem Hochzaehlen geprueft wird.
+ for (int i = 0; i < TICKS_TO_REGENERATE; i++) {
+ foodBar.consume();
+ }
+
+ assertEquals(StaminaBar.State.READY, foodBar.state,
+ "Nach " + TICKS_TO_REGENERATE + " Ticks muss die Bar wieder READY sein");
+ assertFalse(player.hasBlockedSprinting(),
+ "Nach voller Regeneration muss die Sperre aufgehoben sein");
+
+ env.destroyInstance(instance, true);
+ }
+
+ /**
+ * 🛡️ Absicherung: dokumentiert die Wiedereinstiegs-Schwelle von 7 als bewusste
+ * Design-Entscheidung, damit ein spaeterer Balancing-Commit sie nicht versehentlich kippt.
+ */
+ @Test
+ @DisplayName("Wiedereinstieg erst oberhalb der Schwelle")
+ void testResumeThreshold(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance);
+ FoodBar foodBar = new FoodBar(player);
+
+ foodBar.startDraining();
+ for (int i = 0; i < TICKS_TO_DRAIN; i++) {
+ foodBar.consume();
+ }
+
+ // Bei 7 (Schwelle nicht ueberschritten) noch gesperrt ...
+ for (int i = 0; i < 7; i++) {
+ foodBar.consume();
+ }
+ assertFalse(foodBar.canConsume(), "Bei genau 7 darf noch nicht gesprintet werden");
+
+ // ... bei 8 erlaubt.
+ foodBar.consume();
+ assertTrue(foodBar.canConsume(), "Oberhalb von 7 ist Sprinten wieder erlaubt");
+
+ env.destroyInstance(instance, true);
+ }
+
+ /**
+ * Der wertvollste Test dieser Datei: eine Invariante ueber den kompletten Verlauf
+ * statt einzelner Beispiel-Zeitpunkte. Er faengt jede kuenftige Aenderung, die
+ * blockedSprinting und den Bar-Zustand auseinanderlaufen laesst.
+ */
+ @Test
+ @DisplayName("Invariante: gesperrt genau dann, wenn die Bar erschoepft regeneriert")
+ void testBlockedInvariantHoldsThroughout(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance);
+ FoodBar foodBar = new FoodBar(player);
+
+ foodBar.startDraining();
+
+ for (int tick = 0; tick < TICKS_TO_DRAIN + TICKS_TO_REGENERATE + 5; tick++) {
+ foodBar.consume();
+
+ boolean blocked = player.hasBlockedSprinting();
+ boolean exhausted = foodBar.state == StaminaBar.State.REGENERATING;
+
+ assertEquals(exhausted, blocked,
+ "Tick " + tick + ": blockedSprinting (" + blocked
+ + ") muss dem Bar-Zustand (" + foodBar.state + ") entsprechen");
+ }
+
+ env.destroyInstance(instance, true);
+ }
+}
+```
+
+---
+
+### Test 2 — Der eigentliche Regressionstest für Fix 1
+
+`game/src/test/java/net/onelitefeather/cygnus/listener/game/PlayerStartSprintingListenerIntegrationTest.java`
+
+Dieser Test hätte den Bug gefangen. Er ist der wichtigste der drei.
+
+```java
+package net.onelitefeather.cygnus.listener.game;
+
+import net.minestom.server.instance.Instance;
+import net.minestom.testing.Env;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import net.onelitefeather.cygnus.common.Tags;
+import net.onelitefeather.cygnus.movement.PlayerStartSprintingEvent;
+import net.onelitefeather.cygnus.player.CygnusPlayer;
+import net.onelitefeather.cygnus.stamina.FoodBar;
+import net.onelitefeather.cygnus.stamina.StaminaBar;
+import net.onelitefeather.cygnus.stamina.StaminaService;
+import net.onelitefeather.cygnus.team.TeamHelper;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Regression tests for {@link PlayerStartSprintingListener}.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.4.0
+ */
+class PlayerStartSprintingListenerIntegrationTest extends CygnusPlayerTestBase {
+
+ /**
+ * 🔴 Rot vor Fix 1 - das ist der gemeldete Bug.
+ *
+ * Ein gesperrter Spieler haelt die Sprint-Taste gedrueckt. Das Event muss abgelehnt
+ * werden UND die Bar darf sich nicht bewegen. Heute schiebt canConsume() sie zurueck
+ * nach DRAINING, wodurch die Regeneration von vorn beginnt.
+ */
+ @Test
+ @DisplayName("Gesperrter Spieler: Event abgelehnt und Bar unveraendert")
+ void testBlockedPlayerDoesNotMutateBar(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance);
+ player.setTag(Tags.TEAM_ID, TeamHelper.SURVIVOR_TEAM_ID);
+
+ StaminaService staminaService = new StaminaService();
+ staminaService.createStaminaBars(java.util.Set.of(player));
+ FoodBar foodBar = staminaService.getFoodBar(player);
+ assertNotNull(foodBar);
+
+ // Bar leerlaufen lassen -> Spieler ist gesperrt und regeneriert.
+ foodBar.startDraining();
+ for (int i = 0; i < 10; i++) {
+ foodBar.consume();
+ }
+ assertTrue(player.hasBlockedSprinting(), "Vorbedingung: Spieler muss gesperrt sein");
+
+ // Bis ueber die Wiedereinstiegs-Schwelle regenerieren (count = 8).
+ for (int i = 0; i < 8; i++) {
+ foodBar.consume();
+ }
+ assertTrue(player.hasBlockedSprinting(),
+ "Vorbedingung: Sperre besteht bis zur vollen Bar");
+
+ StaminaBar.State stateBefore = foodBar.state;
+
+ var listener = new PlayerStartSprintingListener(staminaService::getFoodBar);
+ var event = new PlayerStartSprintingEvent(player);
+ listener.accept(event);
+
+ assertTrue(event.isCancelled(),
+ "Ein gesperrter Spieler darf nicht sprinten duerfen");
+ assertEquals(stateBefore, foodBar.state,
+ "Ein abgelehnter Sprint darf die Stamina nicht anfassen");
+
+ staminaService.cleanUp();
+ env.destroyInstance(instance, true);
+ }
+
+ /**
+ * 🔴 Rot vor Fix 1: wiederholte Packets ziehen heute mehrfach am Zustandsautomaten.
+ */
+ @Test
+ @DisplayName("Wiederholte Sprint-Events aendern den Zustand nicht mehrfach")
+ void testRepeatedEventsAreIdempotent(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance);
+ player.setTag(Tags.TEAM_ID, TeamHelper.SURVIVOR_TEAM_ID);
+
+ StaminaService staminaService = new StaminaService();
+ staminaService.createStaminaBars(java.util.Set.of(player));
+ FoodBar foodBar = staminaService.getFoodBar(player);
+ assertNotNull(foodBar);
+
+ foodBar.startDraining();
+ for (int i = 0; i < 10; i++) {
+ foodBar.consume();
+ }
+
+ var listener = new PlayerStartSprintingListener(staminaService::getFoodBar);
+ for (int i = 0; i < 20; i++) {
+ listener.accept(new PlayerStartSprintingEvent(player));
+ }
+
+ assertEquals(StaminaBar.State.REGENERATING, foodBar.state,
+ "20 abgelehnte Sprint-Versuche duerfen die Regeneration nicht abbrechen");
+
+ staminaService.cleanUp();
+ env.destroyInstance(instance, true);
+ }
+
+ /**
+ * 🛡️ Absicherung fuer Fix 4: kein NPE, wenn keine Bar registriert ist.
+ */
+ @Test
+ @DisplayName("Spieler ohne registrierte FoodBar wirft keine NPE")
+ void testMissingFoodBarIsHandled(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance);
+ player.setTag(Tags.TEAM_ID, TeamHelper.SURVIVOR_TEAM_ID);
+
+ StaminaService staminaService = new StaminaService(); // bewusst leer
+ var listener = new PlayerStartSprintingListener(staminaService::getFoodBar);
+
+ assertDoesNotThrow(() -> listener.accept(new PlayerStartSprintingEvent(player)));
+
+ env.destroyInstance(instance, true);
+ }
+}
+```
+
+---
+
+### Test 3 — Attribut-Konsistenz {#test-3}
+
+`game/src/test/java/net/onelitefeather/cygnus/player/CygnusPlayerSprintIntegrationTest.java`
+
+```java
+package net.onelitefeather.cygnus.player;
+
+import net.minestom.server.entity.LivingEntity;
+import net.minestom.server.entity.attribute.Attribute;
+import net.minestom.server.instance.Instance;
+import net.minestom.testing.Env;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests the sprint attribute handling of {@link CygnusPlayer}.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.4.0
+ */
+class CygnusPlayerSprintIntegrationTest extends CygnusPlayerTestBase {
+
+ /**
+ * 🔴 Rot vor Fix 3: Cygnus verwendet denselben Key wie Minestoms
+ * geschuetzter SPRINTING_SPEED_MODIFIER. Dadurch ueberlebt der Cygnus-Modifier
+ * jedes clearModifiers() - ein Reset-Pfad kann ihn nicht mehr entfernen.
+ */
+ @Test
+ @DisplayName("Sprint-Modifier kollidiert nicht mit Minestoms geschuetztem Key")
+ void testModifierKeyDoesNotCollide(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance);
+
+ player.setSprinting(true);
+
+ boolean usesProtectedKey = player.getAttribute(Attribute.MOVEMENT_SPEED)
+ .getModifiers().stream()
+ .anyMatch(modifier -> LivingEntity.PROTECTED_MODIFIERS.contains(modifier.id()));
+
+ assertFalse(usesProtectedKey,
+ "Cygnus darf keinen Modifier-Key aus LivingEntity.PROTECTED_MODIFIERS verwenden, "
+ + "sonst ueberlebt er jedes clearModifiers()");
+
+ env.destroyInstance(instance, true);
+ }
+
+ /**
+ * 🔴 Rot vor Fix 3: der gesperrte Zweig kehrt heute frueh zurueck, ohne den
+ * Modifier zu entfernen. Wer beim Sperren gerade sprintet, behaelt den Boost.
+ */
+ @Test
+ @DisplayName("Sperren entfernt den Sprint-Boost unabhaengig von der Aufrufreihenfolge")
+ void testBlockingRemovesSpeedModifier(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance);
+
+ player.setSprinting(true);
+ double sprintingSpeed = player.getAttribute(Attribute.MOVEMENT_SPEED).getValue();
+
+ // Bewusst die "falsche" Reihenfolge: erst sperren, dann setSprinting.
+ player.setBlockedSprinting(true);
+ player.setSprinting(true);
+
+ double blockedSpeed = player.getAttribute(Attribute.MOVEMENT_SPEED).getValue();
+
+ assertTrue(blockedSpeed < sprintingSpeed,
+ "Ein gesperrter Spieler darf keinen Sprint-Boost behalten "
+ + "(gesperrt: " + blockedSpeed + ", sprintend: " + sprintingSpeed + ")");
+ assertFalse(player.isSprinting(),
+ "Ein gesperrter Spieler darf nicht als sprintend markiert sein");
+
+ env.destroyInstance(instance, true);
+ }
+}
+```
+
+---
+
+## 5. Verifikation
+
+```bash
+./gradlew :game:test --tests '*FoodBar*' --tests '*Sprint*'
+```
+
+Checkliste nach allen Fixes:
+
+- [ ] Alle drei neuen Testklassen grün
+- [ ] `StaminaServiceTest`, `StaminaFactoryTest`, `StaminaColorsTest` weiterhin grün
+- [ ] `Alt+F7` auf `FoodBar.startDraining()` → genau ein Aufrufer, hinter dem `return`
+- [ ] `Alt+F7` auf `canConsume()` → nur noch Abfragen, keine Stellen die auf den Nebeneffekt bauen
+- [ ] Grep nach `minecraft:sprinting` im Projekt → keine Treffer mehr
+
+Manuell im Spiel:
+
+1. Als Survivor bis zur Erschöpfung sprinten → Sperre setzt ein, Leiste leer
+2. **Sprint-Taste gedrückt halten** während der Regeneration → die Leiste muss weiter steigen und
+ darf nicht zurückfallen (das war der Bug)
+3. Bei voller Leiste → Sperre löst sich **im selben Moment**, in dem die Leiste voll ist, nicht eine
+ Sekunde später
+4. Nach dem Entsperren sprinten → spürbarer Geschwindigkeitsgewinn
+
+---
+
+## Bewusst ausgeklammert
+
+Diese Befunde sind real, gehören aber zum Slender-Pfad und sind hier **nicht** behandelt:
+
+| Befund | Ort | Wirkung |
+|---|---|---|
+| Toter Guard: `this.time <= 10` auf finaler Konstante `16` | `SlenderBar.java:88` | Slender reaktiviert den Angriffsmodus mit leerer Stamina |
+| `SlenderRevive` stoppt die alte `FoodBar` nicht | `SlenderReviveListener.java:34` | Zwei Bars schreiben gegeneinander auf `blockedSprinting` |
+| `StaminaBar.stop()` hat kein `onStop()` | `StaminaBar.java:57` | Potions, Speed-Basis und `Tags.HIDDEN` überleben den Cleanup |
+| `Tags.HIDDEN` wird relativ statt absolut geschrieben | `SlenderBarTrigger.java:69` | gehört zur separaten Sichtbarkeits-Analyse |
+
+Der erste ist ein Einzeiler mit hoher Wirkung, falls du ihn direkt mitnehmen willst:
+`this.time` → `this.currentTime` (und die Schwelle als benannte Konstante).
diff --git a/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md
new file mode 100644
index 00000000..4f6a0f32
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md
@@ -0,0 +1,209 @@
+# Tunnel vision for survivors
+
+## Goal
+
+A survivor's view narrows as the situation gets worse: the screen edges darken and pulse like a
+heartbeat when stamina runs low, when the Slender closes in, or both. The effect is per player,
+continuous rather than on/off, and driven entirely by the server.
+
+## Why not a shader
+
+The obvious implementation is a post-processing shader, and on Minecraft 26.2 it does not work.
+
+A resource-pack post effect only runs in contexts vanilla decides: the menu blur, spectator mob
+vision, the glowing outline, and the "Improved Transparency" video setting. None of them can be
+switched on for one player from the server, and none carries an intensity parameter. The only way
+to force one on 26.2 is hijacking spectator mob vision by pointing the player's camera at a hidden
+enderman, which takes over the camera and makes the game unplayable.
+
+That changes in 26.3: snapshot 3 (7 July 2026) added `/posteffect add|remove `
+plus the always-on `minecraft:end_of_frame` context. 26.3 is still in snapshots, and Minestom
+ships 26.2 (`net.minestom:minestom:2026.07.22-26.2`).
+
+So the effect is rendered as the `camera_overlay` of an item worn on the head — the mechanism
+behind the carved pumpkin, and the one thing in vanilla that draws a texture across the whole
+screen and scales it with the viewport. Behind an interface that a post-effect renderer can slot
+into once 26.3 and Minestom support land; the gameplay side does not change when that happens.
+
+Reference: [Shader – Minecraft Wiki](https://minecraft.wiki/w/Shader),
+[Java Edition 26.3 Snapshot 3](https://minecraft.wiki/w/Java_Edition_26.3_Snapshot_3).
+
+## Intensity
+
+`TunnelVisionIntensity` turns two inputs into a value in `[0, 1]`. It has no Minestom dependency
+beyond positions, so it is testable without a server.
+
+**Stamina.** With `s = currentSpeedCount / 20`:
+
+```
+stamina = s >= 0.5 ? 0 : ((0.5 - s) / 0.5)^2
+```
+
+Nothing happens above half a bar; below it the curve accelerates, so the last few percent are far
+more dramatic than crossing the halfway mark.
+
+**Slender.** With `d` the distance between survivor and Slender:
+
+```
+proximity = clamp((25 - d) / (25 - 6), 0, 1)
+view = 0.6 + 0.4 * max(0, dot(survivorLookDirection, directionToSlender))
+slender = proximity * view
+```
+
+The effect starts at 25 blocks and peaks at 6. Looking straight at him is worse than having him
+behind you, but never by more than a factor of 1.67 — he is frightening either way.
+
+**Combination:**
+
+```
+combined = 1 - (1 - stamina) * (1 - slender)
+```
+
+Both sources add up noticeably but saturate cleanly at 1.0 instead of clamping hard, so neither
+one can hide the other.
+
+**No line-of-sight raycast.** A wall between survivor and Slender does not dampen the effect. It
+would cost a block walk per survivor per tick, and "I can feel him through the wall" is the better
+atmosphere anyway.
+
+## Stages and pulse
+
+The continuous value is quantised to 16 stages, which double as the frames of the heartbeat.
+Minecraft cannot animate an overlay texture — `.mcmeta` animation covers block, item, particle,
+painting and effect textures only — so the animation is the server walking through the frames. Two mechanisms sit on top, in this order:
+
+1. **Hysteresis on the base value.** `baseStage` starts as `round(combined * 16)` and afterwards
+ only moves when `combined * 16` is more than 0.6 stages away from it. Distance and stamina both
+ jitter constantly; without this the overlay flickers at every stage boundary.
+2. **Pulse on top of the stabilised stage.**
+
+```
+depth = (16 / 16) * combined // one stage per 16, i.e. a fixed share of the scale
+frequency = 1.0 + 1.5 * combined // Hz
+display = clamp(round(baseStage + depth * (sin(2*pi * frequency * t) - 1)), 0, 16)
+```
+
+The heartbeat gets faster and deeper as it gets tighter, and stays nearly invisible at low
+intensity — a depth that does not scale would make stage 1 flicker between 0 and 1.
+
+The pulse only ever opens the view back up, never past the base stage. A symmetric pulse would be
+clipped away exactly where it matters most: at full intensity the base stage is already the
+maximum, so everything above it is lost and the heartbeat disappears.
+
+The order matters: hysteresis applies to the base value, the pulse is added afterwards. Reversed,
+the hysteresis would damp out exactly the pulsing it is there to allow.
+
+Stage 0 is not a texture. It clears the overlay.
+
+**Service tick: 100 ms.** The heartbeat reaches 2.5 Hz, and sampling it at 4 Hz — a 250 ms tick —
+aliases it into something jerky. 100 ms samples it ten times per second, which is smooth and still
+a tiny packet per survivor.
+
+## Pack assets
+
+In `cygnus-pack`, namespace `cygnus`:
+
+```
+pack/assets/cygnus/textures/gui/tunnel_vision/stage_1.png … stage_16.png
+pack/assets/cygnus/equipment/empty.json
+```
+
+Each texture is 1024×576 — 16:9, because the client stretches a camera overlay across the screen
+rather than fitting it. The darkening closes in from all four edges rather than as a circle from
+the middle: it is a superellipse whose exponent eases from 4 at stage 1, a rounded rectangle
+framing the screen, to 2 at stage 16, where a plain ellipse reads as a tunnel. Textures are
+generated by `tools/generate_overlay.py`, which also produces the blood splatter.
+
+**How it reaches the screen.** The server puts an item in the player's head slot carrying
+`equippable{slot:head, camera_overlay:"cygnus:gui/tunnel_vision/stage_N"}`. Three details keep the
+carrier out of the way:
+
+- `asset_id` points at `cygnus:empty`, an equipment model with no layers. Without it Minecraft
+ draws the item itself on the player's head.
+- `swappable`, `dispensable` and `damage_on_hurt` are all off, so nobody strips the overlay by
+ accident and it is not treated as armour.
+- The equip sound is `minecraft:intentionally_empty`; the default would click on every stage
+ change, ten times a second.
+
+**The position needs no calibration.** This is the whole reason for the mechanism: the client
+scales the overlay to the viewport, so it fits every resolution and GUI scale on its own. A font
+glyph cannot — its size is fixed in the pack, so it has to be calibrated against one resolution
+and drifts on every other.
+
+## Components
+
+New package `net.onelitefeather.cygnus.tunnelvision`:
+
+- `TunnelVisionIntensity` — the calculation above. Pure, no server needed to test it.
+- `TunnelVisionStage` — one survivor's overlay state: hysteresis and heartbeat. Also pure.
+- `TunnelVisionRenderer` — `render(player, stage)` and `clear(player)`. This is the seam a
+ post-effect renderer slots into on 26.3.
+- `OverlayTunnelVisionRenderer` — the implementation described above; it contributes a texture to
+ the shared `ScreenOverlay` rather than dressing the player itself.
+- `TunnelVisionService` — holds a `TunnelVisionStage` per survivor and ticks all of them in one
+ scheduler task.
+- `TunnelVisionCommand` — `/tunnelvision stage <0-16> | intensity <0.0-1.0> | off`, for judging the
+ vignette from the lobby without a running round. `stage` freezes one stage to judge the drawing;
+ `intensity` runs the real heartbeat.
+
+One task for everyone rather than one per player as `StaminaBar` does: the Slender position is
+read once per tick instead of once per survivor, and cleanup happens in one place.
+
+## Wiring
+
+`Cygnus` creates the service and the command. The service then listens for the round's lifecycle
+itself, the way `SpectatorService` and `ResourcePackService` already do, rather than being called
+from the existing listeners:
+
+| Event | What happens |
+| --- | --- |
+| `GameStartEvent` | starts drawing for the survivor team |
+| `PlayerDeathEvent` | removes the player (transition to spectator) |
+| `PlayerDisconnectEvent` | removes the player |
+| `GameFinishEvent` | full cleanup |
+
+This keeps `GameStartListener`, `PlayerDeathListener` and `PlayerQuitListener` — and their tests —
+untouched: none of them has anything the service needs beyond the moment itself.
+
+Two changes to existing code:
+
+- **`FoodBar` gains a getter** for normalised stamina. `currentSpeedCount` is private today. The
+ service could read `player.getExp()`, since `FoodBar` mirrors the value there, but that hangs
+ game logic off a display detail.
+- **The service only exists when the resource pack is active.** `Cygnus` creates it only if
+ `resourcePackService` is present, reusing the `Optional` already in place. Without the pack the
+ textures do not exist and players would get a fullscreen missing-texture checkerboard.
+
+## Failure modes
+
+The service keeps running in all of these; none of them throws.
+
+| Situation | Behaviour |
+| --- | --- |
+| No Slender (disconnected, not yet assigned) | stamina share only |
+| Slender in a different instance | slender share is 0 |
+| No `FoodBar` registered for a player | stamina share is 0 |
+| Stage drops to 0 | the layer is dropped rather than drawn — otherwise the last vignette stays on the head |
+| Player dies or becomes a spectator | explicit `clear()`, same reason |
+
+## Tests
+
+- `TunnelVisionIntensityTest` — plain JUnit: edge values (full stamina at long range gives 0,
+ empty stamina at close range gives 1), monotonicity in both inputs, and the view factor.
+- `TunnelVisionStageTest` — plain JUnit: the pulse at full intensity, steadiness at low intensity,
+ hysteresis (a small oscillation around a stage boundary must not change the stage), and bounds.
+- `OverlayTunnelVisionRendererTest` — Cyano: the renderer contributes the expected texture, and
+ `clear()` drops only its own layer rather than wiping the screen out from under the blood
+ splatter.
+- `EquipmentScreenOverlayTest` — Cyano: a layer becomes a camera overlay on the head, the blood
+ wins over the tunnel vision and the tunnel vision returns afterwards, the last layer leaving
+ empties the slot, and an unchanged overlay is not re-sent.
+- `TunnelVisionServiceTest` — lifecycle: start and stop, removing a player, behaviour with no
+ Slender or one in another instance, and the four lifecycle events.
+- `TunnelVisionCommandTest` — the command draws the requested stage, previews an intensity, and
+ clears on `off`.
+- `FoodBarTest` — a fresh bar reports a full share.
+
+The pack side cannot be tested automatically. Glyph sizing and the look of the vignette are
+verified in-game against a snapshot build of `cygnus-pack`; that is an explicit step in the
+implementation plan, not an afterthought.
diff --git a/docs/superpowers/specs/2026-08-11-blood-splatter-design.md b/docs/superpowers/specs/2026-08-11-blood-splatter-design.md
new file mode 100644
index 00000000..f26bd1a5
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-11-blood-splatter-design.md
@@ -0,0 +1,104 @@
+# Blood splatter on damage
+
+## Goal
+
+Taking a hit throws blood across the screen: it appears at once, from the side the hit came from,
+and fades away within about a second. It says nothing about how the player is doing — that is the
+tunnel vision's job — it only says *you were just hit, from over there*.
+
+## Sharing the screen with the tunnel vision
+
+Both effects are full-screen overlays, and both are drawn as the `camera_overlay` of an item on the
+player's head — the only mechanism in vanilla that scales a texture to the viewport instead of
+being calibrated against one resolution.
+
+A player has one head, so **only one overlay can be shown at a time.** A `ScreenOverlay` owns the
+head slot and decides: each effect hands it a texture for its layer (`OverlayLayer.TUNNEL_VISION`,
+`OverlayLayer.BLOOD`, in drawing order) and the topmost one wins. A splatter therefore takes the
+screen for the 1.2 seconds it lasts, and the tunnel vision comes back underneath it afterwards.
+
+The alternative was pre-rendering every combination of splatter frame and vignette stage, so both
+stay visible at once. That is 48 × 4 extra images at the coarsest useful resolution, and every
+change to either effect would force re-rendering all of them.
+
+Two smaller things follow from riding on an item: the carrier points its `asset_id` at an empty
+equipment model so it is never drawn on the player's head, and the overlay is only re-sent when the
+texture actually changes — an equipment update goes out to every viewer, not just the wearer.
+
+## Trigger
+
+Cygnus applies damage in `SlenderBarHelper.applyDamage` by setting health directly. That never
+raises Minestom's `EntityDamageEvent`, so a listener on it would never fire.
+
+`applyDamage` therefore dispatches a `PlayerDamagedEvent` carrying the victim, the source position
+and the amount — the same shape the project already uses for `StaminaStateChangeEvent` and
+`SlenderReviveEvent`. The source position is what lets the splatter be aimed; the amount is not
+used yet but is the natural handle for anything that should scale with how hard the hit was.
+
+## Direction
+
+`BloodDirection.between(victim, source)` reduces the hit to one of four sides, seen from the victim
+rather than from the world:
+
+```
+alignment = dot(victimLookDirection, directionToSource)
+alignment > 0.5 -> FRONT
+alignment < -0.5 -> BACK
+cross(facing, towardsSource).y > 0 -> LEFT, else RIGHT
+```
+
+A hit from the east lands on the left for a player looking south and on the right for one looking
+north. From the exact same spot the direction is meaningless, so it falls back to FRONT.
+
+## Frames
+
+Textures are laid out as direction × variant × frame: 4 × 2 × 6 = 48. The variants keep repeated
+hits from looking mechanical, and the frames are the fade — Minecraft cannot animate a camera
+overlay, so the server steps through them, one every 200 ms, giving a splatter that lives 1.2
+seconds. A fresh hit restarts the sequence rather than queueing behind the old one.
+
+The task that drives the fade starts with the first splatter and stops once nothing is bleeding
+any more, rather than spinning over an empty map between hits.
+
+Drawings are generated by `tools/generate_overlay.py` in `cygnus-pack`: drops are placed with a
+power-law radius — many specks, few real blotches — weighted towards the side the hit came from,
+then blurred and thresholded so they melt into shapes with ragged edges instead of reading as
+confetti. Bigger blotches grow a run downwards that lengthens as the frame fades, and a band along
+the edge the hit came from seals the gaps the drops leave — without it a side splatter looks like
+it stops short of the border. Textures are 1024×576, matching the 16:9 they are stretched onto.
+
+## Wiring
+
+`Cygnus` creates the service and `/blood`, and the service listens for itself:
+
+| Event | What happens |
+| --- | --- |
+| `PlayerDamagedEvent` | throws a splatter from the direction of the source |
+| `PlayerDisconnectEvent` | drops the player's splatter |
+
+Like the tunnel vision, it is only registered when a resource pack is configured — without the
+pack the textures are missing and players would get a fullscreen missing-texture checkerboard.
+
+`/blood [front|right|back|left]` throws one on demand, with no side meaning a random one, so the
+drawings can be judged without waiting to be hit.
+
+## Failure modes
+
+| Situation | Behaviour |
+| --- | --- |
+| Hit while a splatter is still fading | the old one is replaced, the sequence restarts |
+| Hit from the victim's own position | falls back to `FRONT` |
+| Player leaves mid-fade | the splatter is dropped with them |
+| Tunnel vision changes during a splatter | the splatter keeps the screen; the new stage shows once it is over |
+
+## Tests
+
+- `BloodDirectionTest` — plain JUnit: each of the four sides, that the victim's facing decides
+ rather than the world, and the degenerate same-spot case.
+- `BloodSplatterServiceTest` — the first frame appears immediately, the fade walks the frames and
+ cleans up, a second hit restarts, the damage event triggers it, players are independent.
+- `SlenderBarHelperDamageTest` — damage announces the victim and the source, and leaves out the
+ player who dealt it.
+- `BloodCommandTest` — every side can be requested, and the bare command picks one.
+- `EquipmentScreenOverlayTest` — the blood wins over the tunnel vision, the tunnel vision returns
+ afterwards, the slot empties with the last layer, and an unchanged overlay is not re-sent.
diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
index 09584278..6758fe2d 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
@@ -33,12 +33,17 @@
import net.minestom.server.event.player.PlayerSpawnEvent;
import net.minestom.server.event.player.PlayerUseItemEvent;
import net.minestom.server.entity.EntityType;
+import net.minestom.server.entity.Player;
import net.minestom.server.listener.EntityActionListener;
import net.minestom.server.listener.common.SettingsListener;
import net.minestom.server.network.packet.client.common.ClientSettingsPacket;
import net.minestom.server.network.packet.client.play.ClientEntityActionPacket;
import net.onelitefeather.cygnus.ambient.AmbientProvider;
+import net.onelitefeather.cygnus.blood.BloodSplatterService;
+import net.onelitefeather.cygnus.command.BloodCommand;
+import net.onelitefeather.cygnus.command.GlitchCommand;
import net.onelitefeather.cygnus.command.StartCommand;
+import net.onelitefeather.cygnus.command.TunnelVisionCommand;
import net.onelitefeather.cygnus.common.ListenerHandling;
import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap;
import net.onelitefeather.cygnus.common.config.GameConfig;
@@ -47,6 +52,7 @@
import net.onelitefeather.cygnus.common.page.PageProvider;
import net.onelitefeather.cygnus.common.page.event.PageExpiredEvent;
import net.onelitefeather.cygnus.event.GameFinishEvent;
+import net.onelitefeather.cygnus.gaze.SlenderGazeService;
import net.onelitefeather.cygnus.event.SlenderReviveEvent;
import net.onelitefeather.cygnus.event.StaminaStateChangeEvent;
import net.onelitefeather.cygnus.jumpscare.JumpScareManager;
@@ -76,12 +82,22 @@
import net.onelitefeather.cygnus.resourcepack.ResourcePackService;
import net.onelitefeather.cygnus.stamina.SlenderBarTrigger;
import net.onelitefeather.cygnus.stamina.StaminaService;
+import net.onelitefeather.cygnus.stamina.FoodBar;
+import net.onelitefeather.cygnus.overlay.ScreenOverlay;
+import net.onelitefeather.cygnus.overlay.EquipmentScreenOverlay;
+import net.onelitefeather.cygnus.overlay.OverlayProperties;
+import net.onelitefeather.cygnus.tunnelvision.OverlayTunnelVisionRenderer;
+import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer;
+import net.onelitefeather.cygnus.tunnelvision.TunnelVisionService;
import net.onelitefeather.cygnus.utils.StaminaHelper;
import net.onelitefeather.cygnus.view.GameView;
import net.onelitefeather.cygnus.view.GameViewImpl;
+import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Supplier;
/**
@@ -103,6 +119,11 @@ public final class Cygnus implements TeamCreator, ListenerHandling {
private final JumpScareManager jumpscareManager;
private final SpectatorService spectatorService;
private final Optional resourcePackService;
+ private final ScreenOverlay screenOverlay;
+ private final TunnelVisionRenderer tunnelVisionRenderer;
+ private final BloodSplatterService bloodSplatterService;
+ private final TunnelVisionService tunnelVisionService;
+ private final SlenderGazeService slenderGazeService;
public Cygnus() {
Path path = ServiceBootstrap.resolveWorkingDirectory();
@@ -126,6 +147,14 @@ public Cygnus() {
.orElseThrow(() -> new IllegalStateException("Spectator team not found"));
this.spectatorService = new SpectatorService(spectatorTeam, survivorTeam);
this.resourcePackService = ResourcePackService.create();
+ this.screenOverlay = new EquipmentScreenOverlay();
+ this.tunnelVisionRenderer = new OverlayTunnelVisionRenderer(this.screenOverlay);
+ this.bloodSplatterService = new BloodSplatterService(
+ this.screenOverlay,
+ bound -> ThreadLocalRandom.current().nextInt(bound)
+ );
+ this.tunnelVisionService = new TunnelVisionService(this.tunnelVisionRenderer, this::remainingStamina);
+ this.slenderGazeService = new SlenderGazeService(this.screenOverlay, this::currentSlender);
this.initPhases();
this.initCommands();
this.initListener();
@@ -136,6 +165,42 @@ public Cygnus() {
private void initCommands() {
var manager = MinecraftServer.getCommandManager();
manager.register(new StartCommand(this.linearPhaseSeries));
+ manager.register(new TunnelVisionCommand(this.tunnelVisionRenderer));
+ manager.register(new BloodCommand(this.bloodSplatterService));
+ manager.register(new GlitchCommand(this.slenderGazeService));
+ }
+
+ /**
+ * Reads a survivor's remaining stamina for the tunnel vision.
+ *
+ * @param player the survivor to read
+ * @return the remaining share, or a full bar while the player has none yet
+ */
+ private double remainingStamina(Player player) {
+ FoodBar bar = this.staminaService.getFoodBar(player);
+ return bar == null ? 1.0D : bar.remainingShare();
+ }
+
+ /**
+ * Looks up the player currently playing the slender.
+ *
+ * @return the slender, or {@code null} while the role is unassigned
+ */
+ private @Nullable Player currentSlender() {
+ return this.teamService.getTeam(GameConfig.SLENDER_KEY)
+ .flatMap(team -> team.getPlayers().stream().findFirst())
+ .orElse(null);
+ }
+
+ /**
+ * Collects the players that are currently survivors.
+ *
+ * @return the survivor team's players
+ */
+ private Set currentSurvivors() {
+ return this.teamService.getTeam(GameConfig.SURVIVOR_KEY)
+ .map(team -> Set.copyOf(team.getPlayers()))
+ .orElseGet(Set::of);
}
private void initListener() {
@@ -188,6 +253,14 @@ private void registerGameListener() {
MinecraftServer.getPacketListenerManager().setPlayListener(ClientSettingsPacket.class, CygnusSettingsListener::listener);
spectatorService.registerListener(handler);
+
+ // Without the pack the vignette font does not exist and survivors would stare at an
+ // empty box, so the effect stays off wherever the pack is not delivered.
+ if (OverlayProperties.enabled()) {
+ this.tunnelVisionService.registerListener(handler, this::currentSurvivors);
+ this.slenderGazeService.registerListener(handler, this::currentSurvivors);
+ this.bloodSplatterService.registerListener(handler);
+ }
}
private void initPhases() {
diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java
new file mode 100644
index 00000000..6408208d
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java
@@ -0,0 +1,54 @@
+package net.onelitefeather.cygnus.blood;
+
+import net.minestom.server.coordinate.Point;
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+
+/**
+ * The side of the screen a splatter is thrown from, seen from the victim rather than from the
+ * world — being hit from the east means something different depending on where you are looking.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+public enum BloodDirection {
+
+ FRONT,
+ RIGHT,
+ BACK,
+ LEFT;
+
+ /** Above this alignment with the view direction a hit counts as coming from straight ahead. */
+ private static final double FORWARD_THRESHOLD = 0.5D;
+
+ /** Below this distance the direction to the source carries no meaning any more. */
+ private static final double DISTANCE_EPSILON = 1.0E-6D;
+
+ /**
+ * Works out which side a hit came from.
+ *
+ * @param victim the victim's position, whose yaw and pitch supply the view direction
+ * @param source where the damage came from
+ * @return the side to throw the splatter from
+ */
+ public static BloodDirection between(Pos victim, Point source) {
+ double distance = victim.distance(source);
+ if (distance < DISTANCE_EPSILON) return FRONT;
+
+ Vec towardsSource = new Vec(
+ source.x() - victim.x(),
+ source.y() - victim.y(),
+ source.z() - victim.z()
+ ).div(distance);
+ Vec facing = victim.direction();
+
+ double alignment = facing.dot(towardsSource);
+ if (alignment > FORWARD_THRESHOLD) return FRONT;
+ if (alignment < -FORWARD_THRESHOLD) return BACK;
+
+ // The cross product points up when the source sits on the side the victim's left hand is
+ // on, which for a player looking south is the east.
+ return facing.cross(towardsSource).y() > 0 ? LEFT : RIGHT;
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java
new file mode 100644
index 00000000..cec37114
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java
@@ -0,0 +1,169 @@
+package net.onelitefeather.cygnus.blood;
+
+import net.kyori.adventure.key.Key;
+import net.minestom.server.entity.Player;
+import net.minestom.server.event.Event;
+import net.minestom.server.event.EventNode;
+import net.minestom.server.event.player.PlayerDisconnectEvent;
+import net.onelitefeather.cygnus.common.util.PlayerState;
+import net.onelitefeather.cygnus.common.util.RepeatingTask;
+import net.onelitefeather.cygnus.event.PlayerDamagedEvent;
+import net.onelitefeather.cygnus.overlay.OverlayLayer;
+import net.onelitefeather.cygnus.overlay.OverlayTextureKeys;
+import net.onelitefeather.cygnus.overlay.ScreenOverlay;
+
+import java.time.temporal.ChronoUnit;
+import java.util.Iterator;
+import java.util.Locale;
+import java.util.function.IntUnaryOperator;
+
+/**
+ * Throws a splatter of blood across the screen when a player is hit and fades it out again.
+ *
+ * The textures are laid out as direction × variant × frame. The direction aims the splatter at the
+ * side the hit came from, the variant keeps repeated hits from looking mechanical, and the frames
+ * are the fade — Minecraft cannot animate a camera overlay, so the server steps through them.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 2.1.0
+ * @since 2.7.0
+ */
+public final class BloodSplatterService {
+
+ /** How many drawings exist per direction. */
+ static final int VARIANTS = 2;
+
+ /** How many frames a splatter fades over. */
+ static final int FRAMES = 12;
+
+ /**
+ * How long a single frame stays on screen. Twelve frames at this rate keep the splatter alive
+ * for the same 1.2 seconds as six did at twice the interval, but it runs down the screen
+ * smoothly rather than in visible steps.
+ */
+ static final int FRAME_MILLIS = 100;
+
+ /** Where the splatter textures live, as {@code camera_overlay} resolves them. */
+ static final String TEXTURE_PATH = "gui/blood/";
+
+ /** The keys, indexed {@code [direction][variant][frame]}. */
+ private static final Key[][][] TEXTURES = buildTextures();
+
+ private final ScreenOverlay overlay;
+ private final IntUnaryOperator variantPicker;
+ private final PlayerState active = new PlayerState<>();
+
+ /** Fades every active splatter forward by one frame. Runs only while someone is bleeding. */
+ final RepeatingTask fadeTask = new RepeatingTask(this::tick);
+
+ /**
+ * Creates a new service.
+ *
+ * @param overlay the overlay that owns the player's screen
+ * @param variantPicker picks a variant below the given bound
+ */
+ public BloodSplatterService(ScreenOverlay overlay, IntUnaryOperator variantPicker) {
+ this.overlay = overlay;
+ this.variantPicker = variantPicker;
+ }
+
+ /**
+ * Listens for hits and for players leaving.
+ *
+ * @param node the node to register on
+ */
+ public void registerListener(EventNode node) {
+ node.addListener(PlayerDamagedEvent.class, event -> this.splatter(
+ event.getPlayer(),
+ BloodDirection.between(event.getPlayer().getPosition(), event.getSource())
+ ));
+ node.addListener(PlayerDisconnectEvent.class, event -> this.clear(event.getPlayer()));
+ }
+
+ /**
+ * Throws a fresh splatter, replacing whatever is still fading.
+ *
+ * @param player the player who was hit
+ * @param direction the side the hit came from
+ */
+ public void splatter(Player player, BloodDirection direction) {
+ Splatter splatter = new Splatter(player, direction, this.variantPicker.applyAsInt(VARIANTS));
+ this.active.put(player, splatter);
+ this.draw(splatter);
+ this.fadeTask.start(FRAME_MILLIS, ChronoUnit.MILLIS);
+ }
+
+ /**
+ * Takes the splatter off a player's screen.
+ *
+ * @param player the player to clear
+ */
+ public void clear(Player player) {
+ if (this.active.remove(player) == null) return;
+ this.overlay.set(player, OverlayLayer.BLOOD, null);
+ }
+
+ /**
+ * Advances every splatter by one frame and drops the ones that have faded out.
+ */
+ void tick() {
+ Iterator splatters = this.active.values().iterator();
+ while (splatters.hasNext()) {
+ Splatter splatter = splatters.next();
+ splatter.frame++;
+
+ if (splatter.frame >= FRAMES) {
+ splatters.remove();
+ this.overlay.set(splatter.player, OverlayLayer.BLOOD, null);
+ continue;
+ }
+ this.draw(splatter);
+ }
+
+ // Nothing is bleeding; the task would only spin over an empty map until the next hit.
+ if (this.active.isEmpty()) this.fadeTask.stop();
+ }
+
+ /**
+ * Puts a splatter's current frame on its player's screen.
+ *
+ * @param splatter the splatter to draw
+ */
+ private void draw(Splatter splatter) {
+ this.overlay.set(splatter.player, OverlayLayer.BLOOD,
+ TEXTURES[splatter.direction.ordinal()][splatter.variant][splatter.frame]);
+ }
+
+ /**
+ * Builds the texture key for every cell of the direction × variant × frame grid.
+ *
+ * @return the keys, indexed {@code [direction][variant][frame]}
+ */
+ private static Key[][][] buildTextures() {
+ return OverlayTextureKeys.cube(
+ TEXTURE_PATH,
+ BloodDirection.values().length, VARIANTS, FRAMES,
+ direction -> BloodDirection.values()[direction].name().toLowerCase(Locale.ROOT),
+ OverlayTextureKeys.ONE_BASED,
+ OverlayTextureKeys.ONE_BASED
+ );
+ }
+
+ /**
+ * One player's running splatter.
+ */
+ private static final class Splatter {
+
+ private final Player player;
+ private final BloodDirection direction;
+ private final int variant;
+ private int frame;
+
+ private Splatter(Player player, BloodDirection direction, int variant) {
+ this.player = player;
+ this.direction = direction;
+ this.variant = variant;
+ }
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java
new file mode 100644
index 00000000..86addd53
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java
@@ -0,0 +1,4 @@
+@NotNullByDefault
+package net.onelitefeather.cygnus.blood;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java
new file mode 100644
index 00000000..ce198835
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java
@@ -0,0 +1,48 @@
+package net.onelitefeather.cygnus.command;
+
+import net.minestom.server.command.builder.Command;
+import net.minestom.server.command.builder.arguments.ArgumentEnum;
+import net.minestom.server.command.builder.arguments.ArgumentType;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.cygnus.blood.BloodDirection;
+import net.onelitefeather.cygnus.blood.BloodSplatterService;
+
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * Throws a blood splatter on demand, so the drawings can be judged without waiting to be hit.
+ *
+ * {@code /blood} picks a side at random, {@code /blood front|right|back|left} asks for one.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+public final class BloodCommand extends Command {
+
+ /**
+ * Creates the command.
+ *
+ * @param service the service that throws the splatter
+ */
+ public BloodCommand(BloodSplatterService service) {
+ super("blood");
+
+ var direction = ArgumentType.Enum("side", BloodDirection.class)
+ .setFormat(ArgumentEnum.Format.LOWER_CASED);
+
+ this.setDefaultExecutor((sender, context) -> {
+ Player player = CommandSenders.asPlayer(sender, "can bleed.");
+ if (player == null) return;
+ BloodDirection[] sides = BloodDirection.values();
+ service.splatter(player, sides[ThreadLocalRandom.current().nextInt(sides.length)]);
+ });
+
+ this.addSyntax((sender, context) -> {
+ Player player = CommandSenders.asPlayer(sender, "can bleed.");
+ if (player == null) return;
+ service.splatter(player, context.get(direction));
+ }, direction);
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java b/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java
new file mode 100644
index 00000000..8ef1d271
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java
@@ -0,0 +1,48 @@
+package net.onelitefeather.cygnus.command;
+
+import net.minestom.server.command.CommandSender;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.cygnus.common.Messages;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Narrows a {@link CommandSender} down to a {@link Player}, since every preview command in this
+ * package draws on a screen and only a player has one.
+ *
+ * {@code TunnelVisionCommand}, {@code BloodCommand} and {@code GlitchCommand} each hand-rolled an
+ * identical {@code private static @Nullable Player asPlayer(CommandSender)}, differing only in the
+ * error string sent back to the console. This type is that method, extracted once.
+ *
+ *
+ * A static helper was chosen over an abstract base command on purpose. The narrowing check is the
+ * only thing the three commands share — their constructors take different services, their default
+ * executors print different usage lines, and {@code TunnelVisionCommand} alone runs a per-player
+ * preview loop. An abstract base class would force every subclass into one constructor shape and
+ * one inheritance chain to get a single one-line check, coupling command shape to something none of
+ * them actually have in common. A stateless static method carries the shared behaviour without
+ * dragging the unrelated parts of any one command onto the other two, which keeps each command free
+ * to change its syntax, its executor and its scheduling independently.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+public final class CommandSenders {
+
+ private CommandSenders() {
+ }
+
+ /**
+ * Narrows the given sender down to a player, telling them why not if it cannot.
+ *
+ * @param sender the sender to narrow
+ * @param reason the rest of the sentence after {@code "Only players "}, e.g. {@code "can bleed."}
+ * @return the player, or {@code null} if the sender has no screen to draw on
+ */
+ public static @Nullable Player asPlayer(CommandSender sender, String reason) {
+ if (sender instanceof Player player) return player;
+ sender.sendMessage(Messages.withMiniPrefix("Only players " + reason));
+ return null;
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java
new file mode 100644
index 00000000..199d25b1
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java
@@ -0,0 +1,49 @@
+package net.onelitefeather.cygnus.command;
+
+import net.minestom.server.command.builder.Command;
+import net.minestom.server.command.builder.arguments.ArgumentType;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.cygnus.common.Messages;
+import net.onelitefeather.cygnus.gaze.SlenderGaze;
+import net.onelitefeather.cygnus.gaze.SlenderGazeService;
+
+/**
+ * Puts the slender's glitch on screen without him being there, so the drawings can be judged from
+ * the lobby.
+ *
+ * {@code /glitch <1-4>} holds one level, {@code /glitch off} takes it away.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+public final class GlitchCommand extends Command {
+
+ /**
+ * Creates the command.
+ *
+ * @param service the service that draws the tearing
+ */
+ public GlitchCommand(SlenderGazeService service) {
+ super("glitch");
+
+ var level = ArgumentType.Integer("level").between(1, SlenderGaze.LEVELS);
+
+ this.setDefaultExecutor((sender, context) -> sender.sendMessage(
+ Messages.withMiniPrefix("Usage: /glitch <1-" + SlenderGaze.LEVELS + "> | off")
+ ));
+
+ this.addSyntax((sender, context) -> {
+ Player player = CommandSenders.asPlayer(sender, "have a view to lose.");
+ if (player == null) return;
+ service.show(player, context.get(level) - 1);
+ }, level);
+
+ this.addSyntax((sender, context) -> {
+ Player player = CommandSenders.asPlayer(sender, "have a view to lose.");
+ if (player == null) return;
+ service.hide(player);
+ }, ArgumentType.Literal("off"));
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java
new file mode 100644
index 00000000..5f8ad9f3
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java
@@ -0,0 +1,106 @@
+package net.onelitefeather.cygnus.command;
+
+import net.minestom.server.command.builder.Command;
+import net.minestom.server.command.builder.arguments.ArgumentType;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.cygnus.common.Messages;
+import net.onelitefeather.cygnus.common.util.PlayerState;
+import net.onelitefeather.cygnus.common.util.RepeatingTask;
+import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer;
+import net.onelitefeather.cygnus.tunnelvision.TunnelVisionStage;
+
+import java.time.temporal.ChronoUnit;
+
+/**
+ * Puts the tunnel vision on screen without a running round, so the glyph sizes in the resource
+ * pack can be judged from the lobby.
+ *
+ * {@code /tunnelvision stage <0-16>} freezes a single stage, which is what the font's
+ * {@code height} and {@code ascent} are calibrated against. {@code /tunnelvision intensity
+ * <0.0-1.0>} runs the same heartbeat the game uses, to judge how the pulse feels. Both are ended
+ * by {@code /tunnelvision off}.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.1.0
+ * @since 2.7.0
+ */
+public final class TunnelVisionCommand extends Command {
+
+ private final TunnelVisionRenderer renderer;
+ private final PlayerState previews;
+
+ /**
+ * Creates the command.
+ *
+ * @param renderer the renderer that draws the preview
+ */
+ public TunnelVisionCommand(TunnelVisionRenderer renderer) {
+ super("tunnelvision");
+ this.renderer = renderer;
+ this.previews = new PlayerState<>();
+
+ var stage = ArgumentType.Integer("level").between(0, TunnelVisionStage.MAX_STAGE);
+ var intensity = ArgumentType.Double("amount").between(0.0D, 1.0D);
+
+ this.setDefaultExecutor((sender, context) -> sender.sendMessage(
+ Messages.withMiniPrefix("Usage: /tunnelvision stage <0-16> | intensity <0.0-1.0> | off")
+ ));
+
+ this.addSyntax((sender, context) -> {
+ Player player = CommandSenders.asPlayer(sender, "can preview the tunnel vision.");
+ if (player == null) return;
+ this.stopPreview(player);
+ this.renderer.render(player, context.get(stage));
+ }, ArgumentType.Literal("stage"), stage);
+
+ this.addSyntax((sender, context) -> {
+ Player player = CommandSenders.asPlayer(sender, "can preview the tunnel vision.");
+ if (player == null) return;
+ this.startPreview(player, context.get(intensity));
+ }, ArgumentType.Literal("intensity"), intensity);
+
+ this.addSyntax((sender, context) -> {
+ Player player = CommandSenders.asPlayer(sender, "can preview the tunnel vision.");
+ if (player == null) return;
+ this.stopPreview(player);
+ this.renderer.clear(player);
+ }, ArgumentType.Literal("off"));
+ }
+
+ /**
+ * Draws a constant intensity with its heartbeat running until the preview is stopped.
+ *
+ * @param player the player to draw for
+ * @param intensity the intensity to hold
+ */
+ private void startPreview(Player player, double intensity) {
+ this.stopPreview(player);
+
+ TunnelVisionStage stage = new TunnelVisionStage();
+ // The task alone would only draw from its first repetition onward, so the initial stage is
+ // rendered here, the same way BloodSplatterService and SlenderGazeService draw their first
+ // frame before ever starting their own repeating task.
+ this.renderer.render(player, stage.update(intensity));
+
+ RepeatingTask task = new RepeatingTask(() -> {
+ if (!player.isOnline()) {
+ this.stopPreview(player);
+ return;
+ }
+ this.renderer.render(player, stage.update(intensity));
+ });
+ this.previews.put(player, task);
+ task.start(TunnelVisionStage.TICK_MILLIS, ChronoUnit.MILLIS);
+ }
+
+ /**
+ * Ends a running preview, leaving whatever is on screen untouched.
+ *
+ * @param player the player whose preview to end
+ */
+ private void stopPreview(Player player) {
+ RepeatingTask task = this.previews.remove(player);
+ if (task != null) task.stop();
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/event/PlayerDamagedEvent.java b/game/src/main/java/net/onelitefeather/cygnus/event/PlayerDamagedEvent.java
new file mode 100644
index 00000000..15fab507
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/event/PlayerDamagedEvent.java
@@ -0,0 +1,65 @@
+package net.onelitefeather.cygnus.event;
+
+import net.minestom.server.coordinate.Point;
+import net.minestom.server.entity.Player;
+import net.minestom.server.event.trait.PlayerEvent;
+
+/**
+ * Called when a player takes damage from the game.
+ *
+ * Cygnus applies damage by setting health directly, which never raises Minestom's
+ * {@code EntityDamageEvent}. This event fills that gap for everything that needs to react to a
+ * hit — the blood splatter above all — and carries where the hit came from, so the reaction can
+ * be aimed.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+@SuppressWarnings("java:S6206")
+public final class PlayerDamagedEvent implements PlayerEvent {
+
+ private final Player player;
+ private final Point source;
+ private final float amount;
+
+ /**
+ * Creates a new instance of the {@link PlayerDamagedEvent}.
+ *
+ * @param player the player who was hit
+ * @param source where the damage came from
+ * @param amount how much health was taken
+ */
+ public PlayerDamagedEvent(Player player, Point source, float amount) {
+ this.player = player;
+ this.source = source;
+ this.amount = amount;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Player getPlayer() {
+ return this.player;
+ }
+
+ /**
+ * Returns where the damage came from.
+ *
+ * @return the position of the source
+ */
+ public Point getSource() {
+ return this.source;
+ }
+
+ /**
+ * Returns how much health the hit took.
+ *
+ * @return the damage amount
+ */
+ public float getAmount() {
+ return this.amount;
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java
new file mode 100644
index 00000000..01fbb31a
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java
@@ -0,0 +1,69 @@
+package net.onelitefeather.cygnus.gaze;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.onelitefeather.cygnus.common.util.Helper;
+
+/**
+ * Works out how badly the sight of the slender tears a survivor's view apart.
+ *
+ * This is about seeing him, not about him being there: standing behind a survivor does nothing at
+ * all, however close he is. Only once he is inside their field of view does the picture start to
+ * come apart, and it gets worse the nearer he is.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+public final class SlenderGaze {
+
+ /** Nothing to draw: he is out of range, or out of sight. */
+ public static final int NONE = -1;
+
+ /** How many degrees of tearing there are between just visible and right in front. */
+ public static final int LEVELS = 4;
+
+ /** Beyond this distance he is too far away to unsettle anything. */
+ private static final double RANGE = 32.0D;
+
+ /** The distance at which the tearing is at its worst. */
+ private static final double CLOSE = 6.0D;
+
+ /**
+ * How far off the view direction he may stand and still count as seen. Roughly the horizontal
+ * field of view of a default client — the effect belongs on the screen he is on.
+ */
+ private static final double FIELD_OF_VIEW = 0.55D;
+
+ /** Below this distance the direction to him carries no meaning any more. */
+ private static final double DISTANCE_EPSILON = 1.0E-6D;
+
+ private SlenderGaze() {
+ }
+
+ /**
+ * Works out the tearing a survivor gets from where the slender stands.
+ *
+ * @param survivor the survivor's position, whose yaw and pitch supply the view direction
+ * @param slender the slender's position
+ * @return a level between {@code 0} and {@code LEVELS - 1}, or {@link #NONE}
+ */
+ public static int levelOf(Pos survivor, Pos slender) {
+ double distance = survivor.distance(slender);
+ if (distance > RANGE) return NONE;
+ if (distance < DISTANCE_EPSILON) return LEVELS - 1;
+
+ Vec towardsSlender = new Vec(
+ slender.x() - survivor.x(),
+ slender.y() - survivor.y(),
+ slender.z() - survivor.z()
+ ).div(distance);
+
+ if (survivor.direction().dot(towardsSlender) < FIELD_OF_VIEW) return NONE;
+
+ double nearness = (RANGE - distance) / (RANGE - CLOSE);
+ double clamped = Helper.clamp(nearness, 0.0D, 1.0D);
+ return (int) Math.round(clamped * (LEVELS - 1));
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java
new file mode 100644
index 00000000..a21eda61
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java
@@ -0,0 +1,208 @@
+package net.onelitefeather.cygnus.gaze;
+
+import net.kyori.adventure.key.Key;
+import net.minestom.server.entity.Player;
+import net.minestom.server.event.Event;
+import net.minestom.server.event.EventNode;
+import net.minestom.server.event.player.PlayerDeathEvent;
+import net.minestom.server.event.player.PlayerDisconnectEvent;
+import net.minestom.server.instance.Instance;
+import net.onelitefeather.cygnus.common.util.Helper;
+import net.onelitefeather.cygnus.common.util.PlayerState;
+import net.onelitefeather.cygnus.common.util.RepeatingTask;
+import net.onelitefeather.cygnus.event.GameFinishEvent;
+import net.onelitefeather.cygnus.event.GameStartEvent;
+import net.onelitefeather.cygnus.overlay.OverlayLayer;
+import net.onelitefeather.cygnus.overlay.OverlayTextureKeys;
+import net.onelitefeather.cygnus.overlay.ScreenOverlay;
+import org.jetbrains.annotations.Nullable;
+
+import java.time.temporal.ChronoUnit;
+import java.util.Set;
+import java.util.function.Supplier;
+
+/**
+ * Tears a survivor's picture apart while the slender stands in their view.
+ *
+ * This replaces what the tunnel vision used to do when he came near, and it asks a different
+ * question: not how close he is, but whether they can see him. Standing behind a survivor does
+ * nothing at all.
+ *
+ *
+ * A real colour-space shift would need a post-processing shader, and on Minecraft 26.2 those
+ * cannot be switched on for a single player, so this is a camera overlay like the others — the
+ * colour is laid over the world rather than the world being recalculated.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 2.0.0
+ * @since 2.7.0
+ */
+public final class SlenderGazeService {
+
+ /** Where the glitch textures live, as {@code camera_overlay} resolves them. */
+ static final String TEXTURE_PATH = "gui/glitch/level_";
+
+ /** How many frames the tearing runs through. */
+ static final int FRAMES = 4;
+
+ /** How long a frame stays on screen. */
+ static final int TICK_MILLIS = 100;
+
+ private static final Key[][] TEXTURES = OverlayTextureKeys.table(
+ TEXTURE_PATH, SlenderGaze.LEVELS, FRAMES, OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED);
+
+ private final ScreenOverlay overlay;
+ private final Supplier<@Nullable Player> slender;
+ private final PlayerState survivors = new PlayerState<>();
+ private final RepeatingTask task = new RepeatingTask(this::tick);
+
+ private int frame;
+
+ /**
+ * Creates a new service.
+ *
+ * @param overlay the overlay that owns the players' screens
+ * @param slender supplies the current slender, or {@code null} while there is none
+ */
+ public SlenderGazeService(ScreenOverlay overlay, Supplier<@Nullable Player> slender) {
+ this.overlay = overlay;
+ this.slender = slender;
+ }
+
+ /**
+ * Hooks the service into the round's lifecycle.
+ *
+ * Mirrors {@code TunnelVisionService}: the service listens for itself rather than being called
+ * from {@code GameStartListener} and friends, because — unlike {@code AmbientProvider}, which
+ * has no per-player state to speak of — it has to drop an individual survivor's tracking the
+ * moment they die or disconnect, not only when the whole round ends. Folding that into the
+ * round's start and finish hooks would mean widening their signatures for every service that
+ * needs it; listening for itself keeps this self-contained instead.
+ *
+ *
+ * @param node the node to register on
+ * @param survivors supplies the survivors of the starting round
+ */
+ public void registerListener(EventNode node, Supplier> survivors) {
+ node.addListener(GameStartEvent.class, event -> {
+ this.startTask();
+ for (Player survivor : survivors.get()) {
+ this.track(survivor);
+ }
+ });
+ node.addListener(PlayerDeathEvent.class, event -> this.remove(event.getPlayer()));
+ node.addListener(PlayerDisconnectEvent.class, event -> this.remove(event.getPlayer()));
+ node.addListener(GameFinishEvent.class, event -> {
+ this.clearAll();
+ this.stopTask();
+ });
+ }
+
+ /**
+ * Starts the update task. Does nothing if it is already running.
+ */
+ public void startTask() {
+ this.task.start(TICK_MILLIS, ChronoUnit.MILLIS);
+ }
+
+ /**
+ * Stops the update task. Does nothing if it is not running. Leaves whatever is on a tracked
+ * survivor's screen where it is — pair with {@link #clearAll()} where every screen needs wiping
+ * too.
+ */
+ public void stopTask() {
+ this.task.stop();
+ }
+
+ /**
+ * Starts drawing for a survivor.
+ *
+ * @param survivor the survivor to draw for
+ */
+ public void track(Player survivor) {
+ this.survivors.put(survivor, survivor);
+ }
+
+ /**
+ * Stops drawing for a survivor and clears what is left on their screen.
+ *
+ * @param player the survivor to drop
+ */
+ public void remove(Player player) {
+ if (this.survivors.remove(player) == null) return;
+ this.overlay.set(player, OverlayLayer.GLITCH, null);
+ }
+
+ /**
+ * Clears every tracked survivor's screen and forgets all of them.
+ */
+ public void clearAll() {
+ for (Player survivor : this.survivors.values()) {
+ this.overlay.set(survivor, OverlayLayer.GLITCH, null);
+ }
+ this.survivors.clear();
+ }
+
+ /**
+ * Puts one level on a player's screen and leaves it there, for judging the drawings without a
+ * slender to walk in front of.
+ *
+ * This sits on the service rather than a separate type because it draws from the very texture
+ * table {@link #tick()} already builds; splitting it out would mean either rebuilding that table
+ * a second time or exposing it, trading one seam for a worse one over two lines of
+ * {@code GlitchCommand} preview code.
+ *
+ *
+ * @param player the player to draw for
+ * @param level the level between {@code 0} and {@code SlenderGaze.LEVELS - 1}
+ */
+ public void show(Player player, int level) {
+ int clamped = Helper.clamp(level, 0, SlenderGaze.LEVELS - 1);
+ this.overlay.set(player, OverlayLayer.GLITCH, TEXTURES[clamped][this.frame % FRAMES]);
+ }
+
+ /**
+ * Takes the tearing off a player's screen.
+ *
+ * @param player the player to clear
+ */
+ public void hide(Player player) {
+ this.overlay.set(player, OverlayLayer.GLITCH, null);
+ }
+
+ /**
+ * Advances the tearing by one frame and redraws every survivor.
+ */
+ void tick() {
+ if (this.survivors.isEmpty()) return;
+
+ Player currentSlender = this.slender.get();
+ this.frame++;
+
+ for (Player survivor : this.survivors.values()) {
+ int level = this.levelFor(survivor, currentSlender);
+ if (level == SlenderGaze.NONE) {
+ this.overlay.set(survivor, OverlayLayer.GLITCH, null);
+ continue;
+ }
+ this.overlay.set(survivor, OverlayLayer.GLITCH, TEXTURES[level][this.frame % FRAMES]);
+ }
+ }
+
+ /**
+ * Works out the tearing one survivor gets.
+ *
+ * @param survivor the survivor to look at
+ * @param slender the current slender, may be {@code null}
+ * @return the level, or {@link SlenderGaze#NONE}
+ */
+ private int levelFor(Player survivor, @Nullable Player slender) {
+ if (slender == null) return SlenderGaze.NONE;
+
+ Instance instance = slender.getInstance();
+ if (instance == null || !instance.equals(survivor.getInstance())) return SlenderGaze.NONE;
+
+ return SlenderGaze.levelOf(survivor.getPosition(), slender.getPosition());
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/package-info.java
new file mode 100644
index 00000000..2ce2129c
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/package-info.java
@@ -0,0 +1,4 @@
+@NotNullByDefault
+package net.onelitefeather.cygnus.gaze;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java
new file mode 100644
index 00000000..244d80f9
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java
@@ -0,0 +1,142 @@
+package net.onelitefeather.cygnus.overlay;
+
+import net.kyori.adventure.key.Key;
+import net.minestom.server.component.DataComponents;
+import net.minestom.server.entity.EquipmentSlot;
+import net.minestom.server.entity.Player;
+import net.minestom.server.item.ItemStack;
+import net.minestom.server.item.Material;
+import net.minestom.server.item.component.Equippable;
+import net.minestom.server.sound.SoundEvent;
+import net.onelitefeather.cygnus.common.util.PlayerState;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.EnumMap;
+import java.util.Map;
+
+/**
+ * Puts the overlay on screen as the {@code camera_overlay} of an item worn on the head.
+ *
+ * This is the one mechanism in vanilla that draws a texture across the whole screen and scales it
+ * with the viewport — the same one the carved pumpkin uses. A font glyph cannot do that: its size
+ * is fixed in the pack, so it has to be calibrated against a resolution and drifts on every other.
+ *
+ *
+ * A player has one head, so only one layer can be shown at a time. The topmost one wins, which
+ * means a splatter of blood takes the screen for as long as it lasts and the tunnel vision comes
+ * back underneath it afterwards.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+public final class EquipmentScreenOverlay implements ScreenOverlay {
+
+ /**
+ * What the overlay rides on. The item itself is never seen — {@link #EMPTY_ASSET} makes sure of
+ * that — so the material only has to exist.
+ */
+ private static final Material CARRIER = Material.PAPER;
+
+ /**
+ * An equipment model with no layers, from the resource pack. Without an asset id Minecraft
+ * falls back to drawing the item itself on the player's head.
+ */
+ private static final String EMPTY_ASSET = "cygnus:empty";
+
+ /** Vanilla's silent sound; the default equip sound would click on every stage change. */
+ private static final SoundEvent SILENT = SoundEvent.of(Key.key("minecraft:intentionally_empty"), null);
+
+ private final PlayerState