Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions docs/superpowers/specs/2026-08-10-tunnel-vision-design.md
Original file line number Diff line number Diff line change
@@ -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 <player> <effect>`
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.
44 changes: 44 additions & 0 deletions game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import net.minestom.server.network.packet.client.play.ClientEntityActionPacket;
import net.onelitefeather.cygnus.ambient.AmbientProvider;
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;
Expand Down Expand Up @@ -74,6 +75,13 @@
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.utils.ViewRuleUpdater;
import net.onelitefeather.cygnus.view.GameView;
Expand All @@ -82,6 +90,7 @@

import java.nio.file.Path;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;

/**
Expand All @@ -103,6 +112,9 @@ public final class Cygnus implements TeamCreator, ListenerHandling {
private final JumpScareManager jumpscareManager;
private final SpectatorService spectatorService;
private final Optional<ResourcePackService> resourcePackService;
private final ScreenOverlay screenOverlay;
private final TunnelVisionRenderer tunnelVisionRenderer;
private final TunnelVisionService tunnelVisionService;

public Cygnus() {
Path path = ServiceBootstrap.resolveWorkingDirectory();
Expand All @@ -126,6 +138,9 @@ 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.tunnelVisionService = new TunnelVisionService(this.tunnelVisionRenderer, this::remainingStamina);
this.initPhases();
this.initCommands();
this.initListener();
Expand All @@ -136,6 +151,29 @@ public Cygnus() {
private void initCommands() {
var manager = MinecraftServer.getCommandManager();
manager.register(new StartCommand(this.linearPhaseSeries));
manager.register(new TunnelVisionCommand(this.tunnelVisionRenderer));
}

/**
* 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();
}

/**
* Collects the players that are currently survivors.
*
* @return the survivor team's players
*/
private Set<Player> currentSurvivors() {
return this.teamService.getTeam(GameConfig.SURVIVOR_KEY)
.map(team -> Set.copyOf(team.getPlayers()))
.orElseGet(Set::of);
}


Expand Down Expand Up @@ -191,6 +229,12 @@ private void registerGameListener() {
MinecraftServer.getPacketListenerManager().setPlayListener(ClientEntityActionPacket.class, CygnusEntityActionListener::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);
}
}

private void initPhases() {
Expand Down
Loading
Loading