
WebMap
Dedicated-server web map for Valheim 1.0. Live players, shared fog-of-war exploration, player-built structures, forest and logging, and in-game server announcements. Clients need no mods.Valheim WebMap
A server-side mod that publishes a live web map of your world. Share
http://your_ip:port and anyone can watch — clients need no mods. Dedicated
server only.
A fork of h0tw1r3/valheim-webmap rebuilt for Valheim 1.0 (Deep North).
Features
- Explorable map in the browser — wheel zoom, pinch on mobile.
- Shared fog of war: only what players have actually explored.
- Live player list and positions, auto-follow, and in-game pings.
- Structures — placed pieces drawn in the colour of their material, so bases read as bases. Keyed off the piece's creator, so terrain and world-generated ruins never appear. The sweep behind it runs only while someone is looking at the map.
- Forest and logging — standing trees shade the terrain and felled ground stops being shaded, so clearings show through. Stumps are counted as the record of felling.
- Boats and carts, in explored territory only.
- Portals, graves and every placed piece as JSON, for a front-end of your own. The bundled page does not draw them yet.
- World render at the resolution you choose —
render_size4096 halves the metres per pixel, and rendering no longer stalls the server. - Server announcements on every player's screen, for restart warnings and the like.
- Chat, deaths and joins in the message log; optional Discord notifications.
Install
- With BepInEx working, put the
WebMapdirectory inValheim dedicated server/BepInEx/plugins/WebMap. - Start the server once to write a default config.
- Stop the server before editing that config — BepInEx rewrites it on shutdown, so edits made while it runs are discarded.
- Open the configured port (default
8080) and visithttp://your_ip:port.
After updating, hard-reload the page (shift+reload) to clear cached layers.
Using the map
The viewer at http://your_ip:port is four pages — the live map, a portal atlas drawn on
a biome chart, a planning board for drawing and sharing routes, and per-player tallies —
with a legend that switches each layer and a Layers card, as on Google Maps, holding
presets of the legend and the choice of ground: the render, or the flat biome atlas.
The sidebar folds away with the ☰ in the bar, on phones it is a drawer, and the choice
holds across pages. Its source is WebMap/web: site.css is the style system every
page draws from, map-core.js the rendering core, and each page holds only its own.
Our own hosted copy at
xn-valheim deploys the same files with a site-config.js that names our server.
Players only appear once they set visible to other players on the in-game map (m).
Chat commands
!pin— a dot where you stand.!pin some textlabels it.!pin <type> <text>— types aredot,fire,mine,house,cave.!undoPin— remove your most recent pin.!deletePin <text>— remove the pin matching that text. With no text, your most recent unnamed pin.
Not case sensitive. Past the configured limit a player's oldest pin is dropped.
A deployment with a sign-in (see /pins below) lets members do the same from the map:
tap walked ground and Pin here, or tap a pin to relabel, retype, move or delete it.
Server announcements
POST /announce, message as the body, X-Announce-Token header. The secret lives in
announce.token beside the DLL — not in the BepInEx config, which is rewritten on
shutdown and would discard it. No token file means the route is closed.
Configuration
Standard BepInEx config, plus:
render_size— pixels across the world render, default 2048. Same area astexture_size, only sharper: 4096 halves the metres per pixel for a one-time render of about a minute and a larger download. The overlays stay attexture_size, where extra resolution buys nothing. Amap.pngof the wrong size is rebuilt on start.show_vehicles— report boats and carts at/vehicles. They are only ever reported in explored territory; off stops them being reported at all.
HTTP endpoints
| Path | Returns |
|---|---|
/map, /map.jpg |
the world render; the JPEG is about a seventh the size |
/fog |
explored mask (PNG) |
/chart |
the world as a chart: each pixel its biome's flat colour, water one blue, no relief (PNG, once per world) |
/structures, /structures/stats |
structures overlay; counts by prefab and the last sweep's cost |
/forest, /forest/stats |
forest overlay, tree and stump counts with density percentiles |
/trails |
where players have walked: a count per map pixel, drawn as a faint blue band; 503 until the first sweep after someone walks (PNG) |
/features |
the world's geography with its names: landmasses, ranges (with peaks), lakes, bays, rivers (with their course), biome regions; ?v= from rev.features |
/names |
POST {"id","name"} names a place (empty name: back to the world's own) or, with an id of hub@x,z, a portal hub standing there; needs X-Announce-Token, credits X-User |
/pins (POST) |
{"op":"add","x","z","type","text"}, {"op":"edit","id"} with any of x, z, type, text, or {"op":"delete","id"}: places, changes or takes up a pin, only ever on walked ground; answers {"ok","id","pins"}, 400 with {"error"}. Needs X-Announce-Token; X-User owns a new pin and is logged for every write |
/at |
?x=&z= in world metres: the biome and height at a walked spot and the places it lies in, with how much of each has been walked and what stands on it; 404 for unwalked ground |
/pieces |
every placed piece as [prefab, x, z, yaw] against a table of prefab footprint and colour; a torch, fire pit or hearth carries a fifth field, 1 while it has fuel (JSON, about 60 KB for a world) |
/portals |
portals with their tag and the portal each is linked to, as the game has connected them (JSON) |
/graves |
tombstones still holding gear: owner, position, seconds since the death (JSON) |
/vehicles |
boats and carts, position and type (JSON) |
/stats/players |
per-player tallies: joins, deaths, chat, distance, portal hops, pins, standing pieces/portals/ships, graves (JSON) |
/players, /pins, /messages |
live state (JSON) |
/state |
all of the small JSON blocks in one document -- players, messages, pins, vehicles, portals, graves, traders, the last 500 deaths with where they happened -- plus a content revision per layer (rev.fog, rev.forest, rev.structures, rev.pieces, rev.chart, rev.trails) so a viewer fetches a layer only when its picture changed; pass the revision as ?v= |
/announce |
POST, see above |
The structure sweep walks every ZDO on the game thread, a few thousand per frame;
everything after the walk runs on a pool thread. It runs only while someone is reading the map: a request to any layer or to the sweep-fed JSON
arms it for two minutes, sweeps start at least a minute apart, and an idle server does
none at all. /config, /players, /map, /pins and /messages do not arm it, so a
monitor probing those keeps the game idle. /structures/stats reports the last sweep —
ZDOs walked, game-thread milliseconds, frames, wall time, gen-2 collections — so the cost
can be read rather than guessed.
Notes for developers
Chat. Valheim 1.0 addresses chat to each recipient rather than broadcasting it, so a
hook on HandleRoutedRPC sees only pings. It still passes through the server, though:
RPC_RoutedRPC calls RouteRPC to forward it, and that is where this mod observes chat.
A shout arrives once per recipient and is de-duplicated on sender, method and payload.
Upstream instead registered a fake server-side player so clients would address the server. On 1.0 that stops anyone joining at all — the server stays healthy and registered but logs zero connection attempts — so it is gone here. Don't put it back.
The sweep yields only between sector lists. Yielding inside a List<ZDO> lets the
game's removals shift the index under the walk and skip ZDOs; a whole sector between
yields is the smallest safe step.
Announcements use MessageHud's ShowMessage rather than chat: Chat gates every
message on a RelationsManager permission check against the sender's platform user id,
which a server does not have, so chat sent from a server is dropped in silence.
The style system
site.css is the whole look: the tokens on :root (surfaces darkest to lightest, ink,
accents, the map's own hues for portals, traders and graves, the halos over the world,
radii, shadows, fonts, the bar and sidebar widths), the base, and the components every
page shares -- .title .eyebrow .sub .note .section .foot, .card .well .glass .stat .dot,
.btn .chip .field .btnrow, .stage .mapctl .legend, the nav and the sidebar shell. A
page's own <style> holds only what that page alone draws, and names no colour of its
own: every colour is a token, so a restyle is an edit to :root and the pages follow.
The two exceptions are data, not chrome: the forest swatch's greens and the biome
inks the map sets names in (NAME_STYLE in map-core.js).
Tests
dotnet test WebMap.Tests # the mod's logic: builds the mod, then loads WebMap.dll on .NET 10
node --test # map-core.js's pure functions, from the repo root
The first needs libs/ set up as for a build. TESTING.md says what each
layer covers, and what nothing covers yet. CI runs both on every push and pull request.
Checking the viewer against a live server
tools/sameorigin.py WebMap/web http://your_ip:port 8766 serves the viewer the way the
mod does -- the pages from disk, every other path forwarded to the server -- and, with a
headless Chrome started with --remote-debugging-port=9334,
node tools/check.mjs http://127.0.0.1:8766 9334 [/tmp/shots] checks every page over it:
no exceptions, markers and names drawn, a tapped name opening its place card, the names
flyout, hubs and dial lines, a card per player, the plan loading. One line per check, a
non-zero exit on any failure, and a screenshot of each page when given a directory. A
viewer change is done when that is clean.
tools/bench.mjs http://127.0.0.1:8766 9334 pans and zooms the map over the same Chrome
and prints ms per frame: 16.7 is the screen's own rate, anything above it is lag.
Local test server
./testserver.sh runs the same image the hosts do
(indifferentbroccoli/valheim-server-docker) under podman.
./testserver.sh up # first run downloads the game, ~10 min
./testserver.sh deploy # build, copy in, restart
./testserver.sh status # container + endpoints
./testserver.sh logs
Join with Join by IP → 127.0.0.1:2456, password testpass123.
Two things worth knowing: steamcmd fails app_update with
Missing configuration, having downloaded nothing, in roughly one run in four — it is
transient, so just run it again. And on an SELinux host the bind mounts need :z or the
container silently sees nothing.
Licence and credit
MIT where applicable.
- 1.0 update, structures, forest, vehicles, portals, graves and pieces by Hunter Boyd
- Maintained upstream by Jeff Clark
- Original work by Kyle Paulsen
- Background by webtreats, CC BY 2.0