Client-side character and camera improvements for Valheim. One DLL, no dependencies beyond BepInEx, nothing installed on the server.
| Feature | What it does |
|---|---|
| Melee aim | Melee attacks can be aimed up and down, so an enemy on a slope is reachable |
| Aim lean | The upper body pitches toward your aim, so the swing matches where the hit lands |
| Diving | Swim downward instead of being stuck at the surface |
| First person | Engages on zoom, no head bob, body hidden and weapon visible |
| Achievements | Keeps Steam achievements working in a modded session, via the game's own supported bypass |
Built and tested against Valheim l-1.0.14 (build 25364309) with BepInEx 5.4.23.5.
Installable with any BepInEx mod manager, or by dropping the DLL in BepInEx/plugins.
- Install BepInEx for Valheim if it isn't already (ValheimPlus ships it).
- Drop
BetterCharacterController.dllinto<Valheim>/BepInEx/plugins/. - Launch once; the config appears at
BepInEx/config/gameprog.bettercharactercontroller.cfg.
Whether Valheim opens a black console window at launch is controlled by [Logging.Console] Enabled
in BepInEx/config/BepInEx.cfg. Stock BepInEx defaults it to false; the ValheimPlus client
package ships a config that turns it on. So a missing console window says nothing about whether
mods loaded — only that this one setting differs, which is common when a mod manager installs its
own BepInEx config. BepInEx/LogOutput.log is written regardless ([Logging.Disk] Enabled, default
true) and is the thing to read.
Works alongside ValheimPlus: everything here is cosmetic or local movement, and no
networked state is changed, so V+'s enforceMod version check is unaffected.
If you use ValheimPlus, turn off its [FirstPerson] section (enabled = false) in each
client's own config. Both move the camera and they will fight. This mod needs no hotkey — scroll
in and it takes over.
That setting is client-side in V+ (FirstPersonConfiguration extends ClientConfig), so a server
does not push it to anyone: setting it server-side changes nothing, and two players with different
local configs will behave differently for no visible reason. V+ defaults it to false, so a fresh
install is already fine; a config that has been edited may not be. This mod warns at startup if it
finds V+'s first person enabled.
Everything is configurable; the defaults are what the features were tuned to. The dials that matter most:
| Setting | Default | Notes |
|---|---|---|
01 - Melee aim / maxAngleDegrees |
45 | Vertical aim allowed for melee. 90 lets you hit straight up and down, but the camera sits above the character, so attacks can then pass under a close target |
02 - Aim lean / bodyWeight |
0.55 | How far the torso leans. The main dial |
02 - Aim lean / maxYawDegrees |
50 | Spine-twist guard; lower it if the torso ever corkscrews |
03 - Diving / diveKey |
LeftControl | Hold and steer with the mouse |
04 - First person / zoomThreshold |
0.6 | Zoom distance at which first person engages |
04 - First person / exitMargin |
0.15 | How far the zoom must rise above where first person engaged before it disengages. Relative on purpose, so one scroll tick always exits whatever step size the game applies |
04 - First person / exitZoomThreshold |
0.9 | Absolute ceiling only; exitMargin is what normally ends first person |
04 - First person / eyeDropFromTop |
0.12 | Eye height below the top of the hitbox |
BepInEx/LogOutput.log should contain, at startup:
[Info : BetterCharacterController] BetterCharacterController 1.4.0 loaded from F:\SteamLibrary\steamapps\common\Valheim\BepInEx\plugins\BetterCharacterController.dll
The path matters as much as the version. A mod manager that installs to a folder BepInEx does not scan looks exactly like a broken mod, and a stale copy looks exactly like a mod ignoring your config. Both have cost real debugging time here.
If that line is missing, the plugin is not loading and nothing in the config will change
anything — check the DLL really is in BepInEx/plugins. Check the version in that line before
debugging anything else: a stale DLL behaves exactly like a mod that ignores your fixes.
With debugLogging = true you additionally get the list of patched methods, and first person
reports each transition and why it declined to engage:
first person on: zoom 0.42 (leaves above 1.20)
first person off: zoom 1.35 > 1.20
fp idle: zoom 2.30 > 0.60 | distance=2.30 minDistance=0.00 enterAt=0.60 allowFullZoom=True
Startup also warns if a conflicting plugin is loaded — notably this mod's predecessor
SpineAim, or WatchWhereYouStab / VikingsDoSwim / VerticallyChallenged, whose features are
reimplemented here. Two plugins patching the same methods cannot coexist: each keeps its own
state, so they fight over the camera every frame and over hiding the body, which looks exactly
like a bug in this mod.
99 - Advanced / debugLogging prints lean weights, dive state, and the camera's
distance / minDistance / near clip once a second. That last one is how to tell whether
first person is actually engaging.
Each of these fought an existing engine behaviour, and the details are worth recording.
Every Attack carries m_maxYAngle, the vertical range it may be aimed through, applied
in Attack.GetMeleeAttackDir. Vanilla leaves it low or zero on most weapons, which is why
an enemy slightly up a slope can be unhittable — your swing stays level while the target
sits above the arc.
The cap is raised for the duration of that one call and restored immediately afterward. Attack instances come from shared item data, so leaving a modified value behind would quietly alter that item type for the rest of the session.
Valheim already bends the torso toward the look direction — Unity humanoid look-at IK, with
a m_bodyLookWeight field for exactly that. But CharacterAnimEvent.UpdateLookat contains:
if (m_character.InAttack()) weight = 0f;
so it is switched off precisely when you want it, which is why vanilla melee and bow
aiming never tilt the body. This re-issues SetLookAtPosition/SetLookAtWeight in a
postfix; Unity solves look-at only after OnAnimatorIK returns, so ours are the values it
consumes.
Two details that took several attempts:
- Do not rotate bones directly. Writing
Transform.rotationfrom a late update compounds on any frame the Animator does not re-evaluate, which folds the character in half and then snaps back — a per-frame flicker. And the look-at solver overwrites those writes anyway, by a varying amount, becausem_lookAtWeightramps viaMoveTowards. Driving the solver instead of fighting it removes the problem entirely. - Clamp the look direction to the body's own facing. Feeding the solver a raw camera
direction lets the target swing behind a stationary character while you orbit the camera,
and the solver then corkscrews the torso to reach it.
maxYawDegreesandmaxPitchDegreesare that guard; vanilla does the equivalent with a pre-clampedm_headLookDir.
The lean is skipped for Area and None attack types — radial slams centred on the
character, such as a Stagbreaker — where aim direction is not consulted at all.
Character.UpdateSwimming computes a target height of GetLiquidLevel() - m_swimDepth and
drives the rigidbody's vertical velocity toward it. That buoyancy is what pins a swimmer to
the surface. Running after it and setting the vertical velocity from your look pitch
overrides that for the frame; holding the key keeps overriding it, so you descend.
Local movement only. The resulting position is networked by the game as usual, so other players see you underwater without needing the mod.
Valheim networks no view pitch: Character.GetLookDir() is m_eye.forward, and only the local
player's eye is driven by mouse input (Player.SetMouseLook → SetLookDir). For a remote player
that transform carries no pitch at all, so leaning one without extra information aims everybody
identically — worse than leaving them vanilla.
So the mod supplies the missing data itself. Each client publishes its own pitch and yaw and reads what others publish, which means you see where other players are really looking, provided they are also running this mod. Anyone without it is left vanilla rather than guessed at.
The transport is the player's own ZDO rather than a routed RPC. ZDO fields already replicate to every client that can see the character, on the game's own schedule and with ownership enforced, so there is no RPC to register and no recipient list to maintain; they pass through the server as opaque data, so the server needs no mod; the last value persists, so someone who walks into view later gets current angles without keepalive traffic; and clients without the mod simply never read the keys.
Yaw is sent relative to the body rather than as a world direction, so a body that has turned since
the last update still produces a sane result. Angles go out unclamped and each viewer applies its
own limits, so maxYawDegrees and maxPitchDegrees need not match between players. Traffic is
bounded by sendRateHz (10) and minChangeDegrees (1.5) — mouse look jitters fractionally every
frame, and directionSmoothing covers the gaps on the receiving side.
Publishing is independent of whether your client displays a lean, so turning the lean off or
sitting down does not make you invisible to others. [05 - Look sync] enabled = false opts out
entirely.
Two IEEE-754 floats per update: a pitch and a yaw. Nothing else.
The received values are only ever used as numbers — to clamp, to build a Quaternion, and to
position an IK look-at target. They are never used as a type name, an assembly or file path, a
reflection target, a command, or a serialized object graph, and no deserialiser runs on them. There
is therefore no path by which a remote client's data becomes executed code in this mod.
They are still validated at the boundary, because two things can go wrong without any code execution:
- NaN and Infinity survive
Mathf.Clamp— comparisons against NaN are false, so a clamp passes it through. It would reachQuaternion.EulerandSetLookAtPositionand produce an invalid pose plus a Unity error every frame. Non-finite and implausibly large angles are rejected outright and the character is left vanilla, with one warning logged. - Information disclosure. Your view angles are data Valheim does not normally transmit, so
anyone nearby running the mod can tell where you are aiming. That is the point of the feature, but
it is a real disclosure;
enabled = falseif you would rather not.
Worth keeping in perspective: this rides on the game's existing ZDO replication, which already carries position, rotation, health and equipment from every client and is trusted broadly by the game itself. Two range-checked floats do not meaningfully widen that surface — but the surface was already wide, and this mod cannot narrow it.
First person and diving are inherently local, so multiplayer does not affect them: the resulting camera and position are yours, and other players see your position move as normal.
Three separate traps, each of which looked like a positioning bug:
-
m_minDistancefloors the zoom, and where you write it matters. It sits well above any sensible first-person threshold, so without forcing it to 0 the mode can never engage — you simply stay in vanilla's closest third-person view, which orbits a pivot and therefore rises when you look up and sits forward of the body when level. Symptoms that look exactly like broken first-person code, while none of that code is running.UpdateCamerareads that field into a local at the top and clamps against it at the bottom, so it must be written from a prefix onUpdateCamera, not from a postfix further out. A postfix write only lands for the next call, and ValheimPlus has its own prefix on the same method writing the same field — whoever writes last before the original body wins.Priority.Lastsecures that position. -
Offsets must be in the body's frame, not the view's. An offset along the camera's forward vector ties camera height to pitch and pokes out of the hitbox when level. Here they are in the character's horizontal frame and default to zero, so the camera sits on its rotation pivot.
-
Entry and exit need separate thresholds. Sharing one makes the engage and disengage conditions fight the moment anything nudges
m_distance, and first person flickers in and out every frame.zoomThresholdandexitZoomThresholdare that hysteresis. -
UpdateNearClippingrewrites the near plane every frame fromm_nearClipPlaneMin, so settingcamera.nearClipPlaneonce on entry is undone immediately. Both the field and the live value are enforced each frame.
Height comes from the capsule hitbox (Character.GetHeight()), not the head bone. The
hitbox is not animated and does not change when crouching, so there is no bob to damp
rather than bob that has been smoothed — and crouching, swimming and rolling need no
special cases. verticalSmoothing only softens stair steps, and never touches horizontal
position or look, so it adds no aiming lag.
Rescanning for renderers to hide is event-driven: VisEquipment instantiates every equipment
model from one of three methods (AttachItem, AttachArmor, AttachBackItem), and a postfix on
those marks the renderer set dirty. There is no "equipment changed" event to use instead —
CustomUpdate calls UpdateVisuals every frame regardless — so hooking creation is what avoids
walking the player hierarchy on idle frames.
What gets hidden is decided by equipment slot, read from VisEquipment's own per-slot instances:
everything is hidden except m_rightItemInstance and m_leftItemInstance, the things in your hands.
Renderer type is not a usable signal here — helmets are not skinned meshes and some weapons are, so
"hide skinned meshes" left helmets blocking the view while erasing weapons. Hidden renderers are set
to ShadowsOnly:
ShadowsOnly rather than forceRenderingOff matters, because fully hiding the renderers
makes Unity treat the character as off-screen and stop evaluating the Animator, freezing the
skeleton so the held weapon no longer follows the swing. Animator.cullingMode is also
forced to AlwaysAnimate for the same reason, and Character.SetVisible is suppressed for
your own character so the LODGroup cannot cull the body — and the weapon with it.
That suppression needs undoing carefully. SetVisible early-returns when the requested value
already matches m_lodVisible, so a suppressed call never updates the flag and the game will
not retry after suppression stops — it thinks the LODGroup is already as it asked. Leaving
first person therefore forces SetVisible(true) once, with the flag cleared first so the
equality check cannot swallow it. Original shadowCastingMode values are also recorded once
per renderer and never overwritten, since re-capturing an already-concealed value as the
"original" would make the hide permanent.
./build.shNeeds the .NET SDK. It references Valheim's own assemblies, so nothing has to be copied out of a Windows install:
# defaults assume ../ValheimServer/server; override for any other install
GAME_MANAGED=/path/to/valheim_Data/Managed \
BEPINEX_CORE=/path/to/BepInEx/core \
./build.shTargets netstandard2.1, matching the game's own assemblies (Unity 2022.3).
Patches are bound by method name, so a game update that renames one fails loudly in
BepInEx/LogOutput.log at startup rather than crashing. Grep it for
Failed to apply patch / MissingMethodException after any update.
The melee-aim and diving features cover the same ground as WatchWhereYouStab and VikingsDoSwim. They are independent implementations against the game's own API — no code or assets from those mods are included or redistributed here. If you run either of them alongside this, disable the overlapping feature on one side or they will both patch the same behaviour.
MIT — see LICENSE.