Geckuss-DeathRoulette icon

DeathRoulette

When a player dies, a random event fires for everyone. Server-side; players need nothing, admins use Server Devcommands for in-game control.

Last updated 2 days ago
Total downloads 21
Total rating 0 
Categories Mods Server-side AI Generated
Dependency string Geckuss-DeathRoulette-1.2.0
Dependants 0 other packages depend on this package

This mod requires the following mods to function

denikson-BepInExPack_Valheim-5.4.2350 icon
denikson-BepInExPack_Valheim

BepInEx pack for Valheim. Preconfigured with the correct entry point for mods and preferred defaults for the community.

Preferred version: 5.4.2350
JereKuusela-Server_devcommands-1.113.0 icon
JereKuusela-Server_devcommands

Enables devcommands and utilities for server admins.

Preferred version: 1.113.0

README

DeathRoulette

When a player dies on the server, a random event fires for everyone.

Server-side only. Players install nothing — install it on a dedicated server and every connected vanilla client sees the effects.

Built and verified against game build l-1.0.12 (network 40, world 41), BepInEx 5.4.23.5.

Source: https://github.com/Geckuss/valheim-deathroulette

How death is detected

Player.OnDeath() starts with:

if (!m_nview.IsOwner()) { Debug.Log("OnDeath call but not the owner"); return; }

A player's ZNetView is owned by that player's own client, so Player.OnDeath never runs on a dedicated server. Patching it server-side compiles, loads, and silently does nothing.

What the server does see is the broadcast made from inside OnDeath():

m_nview.InvokeRPC(ZNetView.Everybody, "OnDeath");   // Everybody == 0

That is a routed RPC. ZRoutedRpc.RPC_RoutedRPC on the server passes anything addressed to peer 0 through HandleRoutedRPC before relaying it to the remaining peers, so a postfix on ZRoutedRpc.HandleRoutedRPC sees every player death, with the dying player's ZDOID in RoutedRPCData.m_targetZDO. Name and position come off the ZDO (ZDOVars.s_playerName, zdo.GetPosition()).

"OnDeath" is a generic RPC name, so the watcher also checks zdo.GetPrefab() == "Player".GetStableHashCode().

What a server can do to vanilla clients

Everything in ServerActions drives RPCs the stock client already registers:

Action RPC Registered in
Chat announcement (Shout, not distance-culled) ChatMessage Chat
Status effect on a player RPC_AddStatusEffect SEMan
Heal a player RPC_Heal Character
Damage a player RPC_Damage Character
Stagger a player RPC_Stagger Character
Animation hit-stop RPC_FreezeFrame Character
Make a player loud RPC_AddNoise Character
Teleport a player RPC_TeleportTo Character
Play a VFX/SFX prefab SpawnObject ZNetScene
Start a real raid SetEvent via RandEventSystem.SetRandomEventByName RandEventSystem
Skip to morning EnvMan.SkipToMorning (sets net time)
World state ZoneSystem.SetGlobalKey

ZNet.IsDedicated() is a lie

In build l-1.0.12 it returns a hardcoded false, so it cannot be used to detect a dedicated server. IsServer() returns the real m_isServer and is what everything here uses.

Sounds and visual effects do work server-side

ZNetScene.RPC_SpawnObject just calls Instantiate(prefab, pos, rot) with no owner guard, so broadcasting SpawnObject makes every client render its own copy — which is exactly right for non-networked VFX/SFX prefabs. So the server can play sounds and spawn effects without a client mod. The limit is that it can only name assets the client already ships; custom audio or VFX would still need a client-side mod, as would HUD text and screen shake.

The duplicate-spawn trap

The same broadcast is wrong for anything with a ZNetView: N players would each instantiate their own creature, giving N copies with N ZDOs. ServerActions.SpawnCreature therefore targets exactly one peer. ServerActions.PlayEffect broadcasts. Keep the two apart.

Per-ZDO RPCs are safe to broadcast

RPC_Heal, RPC_AddStatusEffect and RPC_AddNoise each begin with their own IsOwner() check, so broadcasting to Everybody with a target ZDO lets the owning client act and every other client no-op. That avoids having to resolve the owning peer. RPC_Stagger and RPC_FreezeFrame are deliberately not owner-guarded — vanilla broadcasts them too, because the animation should play on every client that can see the character.

Commands

Admins drive the mod from the in-game developer console, through Server Devcommands (a declared dependency; install it on the server and on the admin's client). Press F5 and prefix commands with server so they run on the server, where the plugin lives:

server dr status            # master switch, cooldown, who is online and where
server dr list              # every event with its weight
server dr fire thunderclap  # trigger one event
server dr roll              # roll the table as if someone died
server dr fx <prefab>       # audition any effect prefab
server dr stopraid          # end the running raid
server dr reload            # re-read the config file in place
server dr help              # full list

Only players on the server's adminlist.txt can run these; Server Devcommands enforces that. The command is registered as both deathroulette and the shorter dr.

Testing it

The death hook is confirmed working in production -- a real death on a live server produced Thorkell died -> 'loud_grief' in the log. What is worth checking by hand is whether each individual effect actually lands visibly, since several resolve names on the client that the server cannot validate.

Without a client mod, dev/dr.sh drives the same commands over SSH (it writes the polled command file directly), which needs no restart:

./dev/dr.sh list                 # every event with its weight
./dev/dr.sh status               # master switch, cooldown, who is online and where
./dev/dr.sh fire thunderclap     # trigger one event
./dev/dr.sh roll                 # roll the table as if someone died
./dev/dr.sh reload               # re-read the config file in place

Stand in game, fire an event, and see whether anything happens. fire refuses when nobody is online, because every effect needs a player to act on. A manual fire sets DeathContext.IsTest, which makes events that normally act on "everyone except the corpse" act on everyone instead -- otherwise an admin testing alone is the stand-in victim, the survivor list is empty, and blood_tax, second_wind, scatter and summon_mourners all decline with nothing to show.

Some events need two or more players online to show their real behaviour: summon_mourners teleports a lone test firer to their own position, swap_places declines outright, and grave_guardian can only prove that a spawn happens, not that it happens once (the duplicate it guards against needs two peers). If an event announces but nothing visibly happens, the effect name is wrong rather than the plumbing -- see Name accuracy.

dev/ holds personal deployment and testing scripts (deploy.sh, dr.sh, setup-libs.sh). They are configured through environment variables (VALHEIM_SSH_HOST, VALHEIM_CONTAINER, VALHEIM_BEPINEX, VALHEIM_PLUGIN_DIR, VALHEIM_DIR) and are not part of the published package.

Why commands are a file and not chat

Chat was tried first and cannot work for this. Two independent reasons:

Chat.InputText does text = text[0] != '/' ? "say " + text : text.Substring(1) and then runs the result as a local console command, so anything typed with a leading slash never leaves the client.

Worse, chat never reaches a dedicated server when the admin is alone. Both Chat.SendText and Talker.Say fan out one RPC per online player through CheckPermissionsAndSendChatMessageRPCsAsync, each addressed to that player's own peer. And InvokeRoutedRPC handles a message addressed to your own id locally while explicitly declining to route it:

if (targetPeerID == m_id || targetPeerID == 0L) HandleRoutedRPC(data);
if (targetPeerID != m_id) RouteRPC(data);          // own id -> never leaves the client

A dedicated server has no local player and never appears in the player list, so with one player online there is nobody else to address and nothing is sent at all.

So the server polls bepinex/deathroulette.cfg's directory for a deathroulette.cmd file once a second (a timer check plus one File.Exists), runs each line, and writes the reply to deathroulette.out. dr.sh wraps that over SSH.

Announcements

These go to MessageHud, the same place a raid announces itself, via the routed ShowMessage RPC:

  • Center -- the event's display name, in the big four-second crossfade banner. The "The ground is shaking" slot.
  • TopLeft -- who died, and the flavour line. These queue rather than replace each other, and each is written to the player's message log by AddLog.

Death and effect are separate lines. The death fires on every death, before the enabled and cooldown checks, so deaths are still named when the roulette is off or cooling down.

Events return their flavour text rather than announcing it themselves, which keeps phrasing in one place; returning null is how an event declines and triggers a re-roll.

Chat cannot carry these, at all

An earlier version shouted announcements into chat and every one was silently discarded client-side, with nothing in the server log. Two independent gates:

// Chat.OnNewChatMessage -> RelationsManager.CheckPermissionAsync
if (!user.IsValid) { completedHandler(Error); return; }        // not granted -> dropped

// Terminal.AddString
if (!ZNet.TryGetPlayerByPlatformUserID(user, out var playerInfo)) return;   // dropped
string text2 = ...playerInfo.m_name...        // a custom sender name is never used

A chat message must be attributed to a real connected player, and the displayed name comes from the player list rather than from the UserInfo you send. A dedicated server is neither a player nor a valid platform user. MessageHud.RPC_ShowMessage, by contrast, is just ShowMessage((MessageType)type, text) -- no owner check, no permission check, no sender lookup, and it runs the text through Localization.

The event table

34 events. Cut any of them by setting its weight to 0 in the config; no rebuild needed.

Id Flavour Weight What it does
taunt Harmless 9 Odin passes comment. Nothing else.
bossstone_omen Harmless 7 Boss-stone activation effect at everyone's feet.
gjall_lament Harmless 7 A gjall's taunt echoes over every player.
moment_of_silence Harmless 7 Everyone's animation hitches briefly.
phantom_numbers Harmless 6 Floating text appears over every player. Cosmetic.
forsaken_gift Kind 7 A random forsaken power (GP_*) for everyone.
windfall Kind 7 Scatters a pile of materials on the ground at the corpse.
second_wind Kind 6 Survivors healed to full.
rested Kind 4 Rested buff for everyone.
dawn_mercy Kind 4 Time skips to the next morning.
revealed_path Kind 4 Pins the nearest landmark on each map, faces them to it.
gods_relent Kind 4 Ends the raid currently running. Declines if none.
feather_fall Kind 4 Slow Fall on everyone: no fall damage for a while.
adrenaline_rush Kind 4 A surge of adrenaline for everyone.
mistveil Kind 3 Demister on everyone: the mist parts.
sheltered Kind 3 Shelter and Campfire comfort on everyone.
thunderclap Mean 7 Lightning strikes every player for a little damage.
shared_pain Mean 5 Random harmful status on everyone.
blood_tax Mean 5 Survivors lose up to 25% of current health. Never lethal.
stagger_all Mean 5 Everyone staggered, brief loss of control.
loud_grief Mean 4 Everyone becomes loud, attracting creatures.
deep_chill Mean 4 Cold and Freezing on everyone.
tar_soaked Mean 4 Tared and Slimed on everyone: slow and clumsy.
pyre Mean 4 Burning plus fire damage on everyone.
plague Mean 4 Poison plus poison damage on everyone.
soaked Mean 3 Everyone gets Wet.
smoked_out Mean 3 Smoked on everyone: no resting until it clears.
raid Brutal 10 A real raid: music, banner, spawners. Gated by default.
summon_mourners Brutal 5 Survivors teleported to the corpse, wherever it is.
swarm Brutal 5 A handful of small creatures spawned on the corpse.
scatter Brutal 4 Survivors thrown 20-60m in a random direction.
grave_guardian Brutal 4 One creature spawned to guard the corpse.
swap_places Brutal 3 Two random players trade positions. Needs two online.
pilgrimage Brutal 1 Everyone sent to the last defeated boss's altar, boss woken.

Two safety rails: blood_tax and thunderclap read ZDOVars.s_health and cap damage at a fraction of current health, so a rolled event can never itself be lethal and recurse; and a global cooldown (default 30s) stops a party wipe firing five raids at once.

Events that cannot apply — nobody else online for second_wind, no raid running for gods_relent, no boss defeated yet for pilgrimage — return null and are re-rolled rather than wasting the death.

Progression gating

The raid event has two modes, chosen by [Raids] UseVanillaGating (default on):

  • Gated (default). The mod asks vanilla which raids are valid right now and where, so progression (global keys), biome and base checks all apply -- a Meadows base never gets an Ashlands raid.
  • Ungated. RandEventSystem.SetRandomEventByName sets the event directly and does not run those checks, so anything in [Raids] Pool fires regardless of progress. Use this for chaos.

Boss summons (boss_*, hildirboss1..3) are never offered by the raid event: vanilla marks them m_random = false and the gating honours that. The pilgrimage event summons a boss deliberately, and only the last one the world has already defeated.

Name accuracy

Three name pools are resolved at different places, which matters when tuning:

  • Raid names are defined in game prefabs, which vary by world and version. [Raids] Pool is left empty by default, meaning "whatever vanilla currently permits", which is the simplest correct setting with gating on. To pin a specific list, read the world's real names off the running server (logged on startup by StartupPatch) and set them in config, not code.
  • Effect prefabs are resolved by the client's ZNetScene. 1892 real names extracted from the build's asset manifest are in reference/effect-prefab-names.txt; the ones used here were taken from that list.
  • Status effects are resolved by the client's ObjectDB, which the server has no copy of, so a wrong name fails silently with no log. The GP_* powers were verified against the manifest; the vanilla status names used here (Burning, Frost, Poison, Wet, Smoked, Rested, Cold, Freezing, Tared, Slimed, SlowFall, Demister, Shelter, CampFire) are long-standing but not statically verified. Confirm them in play.

Layout

src/Plugin.cs          BepInEx entry point, config, command-file poll
src/DeathWatcher.cs    Harmony patch that detects death server-side
src/ServerActions.cs   server -> vanilla client primitives
src/RouletteEvents.cs  the weighted event table
src/StartupPatch.cs    logs the world's real raid names on start
src/Announce.cs        the two announcements (who died, what fired)
src/CommandFile.cs     polled admin command channel (SSH/file)
src/ConsoleCommands.cs 'dr' developer-console command (via Server Devcommands)
src/EffectSpec.cs      effect-prefab layering parser
lib/                   reference assemblies (game + BepInEx), not redistributed
reference/             effect prefab names extracted from the build
dev/                   personal deploy/test scripts, not part of the package

Build

dotnet build -c Release

netstandard2.1, matching BepInEx 5 on Unity Mono. No assembly publicizer needed: ZRoutedRpc.RoutedRPCData is public, and Harmony patches private methods by name.

The build needs the reference assemblies in lib/, which are copyrighted game and BepInEx binaries and are gitignored rather than committed. Populate them from a local install and your server with dev/setup-libs.sh (see the script for the env vars it reads).

Install

Drop DeathRoulette.dll into your dedicated server's BepInEx/plugins/ directory and restart. Clients need nothing. On Thunderstore/Hexium the package installs into BepInEx/plugins/Geckuss-DeathRoulette/.

dev/deploy.sh automates the build-ship-restart loop for a dockerised server; it is env-var driven (VALHEIM_SSH_HOST, VALHEIM_CONTAINER, VALHEIM_PLUGIN_DIR) and --force skips the check that refuses to restart while players are connected. A restart kicks everyone online.

Config

BepInEx/config/geckuss.deathroulette.cfg, generated on first run.

[General]Enabled, AnnounceDeaths, AnnounceEffects, CooldownSeconds (default 30), VerboseLogging (logs every death and roll).

[Weights] — one entry per event id, described and defaulted as in the table above. 0 disables.

[Raids]UseVanillaGating (default on), Pool (empty by default; see Name accuracy).

[Effects] — override the prefab lists for Thunderclap, AncientOmen, Lament, and the ThunderclapDamage. Effects layer with + (played together) and vary with | (one picked at random).

[Events]PilgrimageSummonsBoss (default on): turn off to keep the mass teleport without respawning the boss.