You are viewing a potentially older version of this package. View all versions.
Mubs-WebMap-2.16.0 icon

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.

Date uploaded 2 weeks ago
Version 2.16.0
Download link Mubs-WebMap-2.16.0.zip
Downloads 52
Dependency string Mubs-WebMap-2.16.0

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

README

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).

screenshot

Features

  • Explorable map in the browser — wheel zoom, pinch on mobile.
  • Shared fog of war: only what players have actually explored.
  • Live player positions, and who is online.
  • 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 traders, linked portals joined by a line; up close, builds turn into a floor plan of every placed piece.
  • The land named -- landmasses, ranges and peaks, lakes, bays, rivers -- and trails where people walk, and where they died.
  • World render at the resolution you choose — render_size 4096 halves the metres per pixel, and rendering no longer stalls the server.
  • The world in 3D. Drop the little figure on any walked spot, as in Street View, and you stand there at eye height: the ground as the game shapes it, terraforming included, water, and every building, tree and rock drawn with the game's own model, under a sky lit by the server's clock, and the players online standing there as themselves, in what they wear. Look around, walk, or switch to an overview.
  • A cinematic tour of the world's places, on the map and in 3D, for a screen left open.
  • Pages of their own for the world's records and bosses, a portal atlas, a planning board, and each player's tallies and class.
  • Server announcements on every player's screen, for restart warnings and the like.
  • Chat, deaths and joins in the message log; the server going up and down, joins and leaves to a Discord webhook; with a bot of your own, an audit log and chat relayed both ways (Discord).
  • Sign in with Discord: members of your Discord server name places and pin the map from the browser, and its admins change the settings there. No bot or Discord app needed.

Install

  1. With BepInEx working, put the WebMap directory in Valheim dedicated server/BepInEx/plugins/WebMap.
  2. Start the server once to write a default config.
  3. Stop the server before editing that config — BepInEx rewrites it on shutdown, so edits made while it runs are discarded.
  4. Open the configured port (default 3000) and visit http://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 five pages — the live map (with the world in 3D as a mode of it, and a cinematic tour through both), the world's own page (its records, its lands, its bosses), a portal atlas drawn on a biome chart (a hub opens close up: the base's floor plan with each gate where it stands, and going through a gate lands you at its far side with the gate you arrive at lit), a planning board for drawing and sharing routes, and per-player tallies, from deaths and deeds to what each has cooked, brewed and smelted, with a class read from their gear, their food and their deeds (a player's own page draws them as the 3D view does, turning slowly: live while they are online, as last seen once they have gone) — 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, js/card.js a player as the roster reads them (for the players page and the tour), 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).

The cinematic

The ▶ among the map's buttons, or a link ending in #tour, plays the world for a screen left open, about twenty places a cycle drawn through every kind before any repeats, the next cycle picking what this one did not: who is on, the portal hubs and the loneliest gate, the biggest builds and each builder's own ("Rurik's stronghold"), where people die, the newest death and the oldest grave, the traders, every boat and cart, the busiest kitchen, the lands, the tallest peak and a ridge flown along its peak line, the biggest lake, the longest river, a bay and its shore flown from the water, a dock or a beach looking out to sea, the pins and names players gave, the world's own locations (boss altars, burial chambers, troll caves, sunken crypts, fuling villages, dvergr outposts, infested mines, tar pits, ruins, wrecks, runestones, stone circles, Hildir's hideouts), and where people go, the trails over the whole walked world. Each is flown to on the map with the layer that makes it worth seeing, landed on at a zoom of its kind (a build at plan zoom, drifted across), then seen in 3D where the ground is walked by a move of its kind: circled at a pitch of its own, swept low, craned up from a man's height to an overview, dollied in from far off, a land from kilometres up at a slant, or flown across a settlement, along a ridge or a shore -- or stood in at eye height: in a base's yard turning slowly, walking through the settlement or along the water, looking out to sea from a dock, down from a summit, or round the gates of a hub, always where the eye sees out rather than into a wall; a land from afar or a coast is seen at the low sun, the rest by day or toward dusk, and the view goes back to the server's clock after. The caption names the place with a strip of figures matched to it -- a hub's gates and its farthest pair, a build's pieces and its top builder's share, whose deaths at a bad spot, a land's area and walked share, a location's kind and biome, the meals at a kitchen -- and every third or fourth stop is one of the world's figures over a slow drift of the whole walked world. Now and then the map pulls slowly back instead of cutting away. Any touch ends it. A stop about a player -- someone online, the newest death, a grave -- carries their card at the side, as their row on the players page reads them: the class, the pentagon, the four figures, and their Viking turning beside them (a phone keeps the figures alone).

While one place plays, the 3D view builds the next -- its ground, objects and models, and the far rings its distance needs -- and the cut waits until the chunks around it are in, holding the map a few seconds longer if need be, and leaves a place still loading on the map rather than cut into a half-built world. Only the places being shown and coming next are held, so the world the tour has left goes.

The 3D view

A mode of the map, entered as Street View is: drag the figure at the top of the map's buttons onto walked ground and drop it (on a phone: tap it, then tap the map), pick 3D under View in the Layers card (you stand in the middle of the map), or See in 3D on a place or a pin. #3d=<x>,<z> (world metres; &look=<degrees from north> optional) links straight in, and the hash follows you as you go. You stand at eye height, 1.8 m above the ground, looking north. Drag to look around; the wheel or a pinch zooms the eye, not the distance; W A S D or the arrows walk (Shift runs, Q E turn); a click on the ground walks there. ⇅ swaps in an orbit camera over the same spot, and back; ☼ holds the light (the server's clock, or a time you pick), shadows (off on phones) and what is shown. Map or Esc puts the map back, centred where you stood, and the hash back to #at. The renderer and its three.js load on the first step into 3D, never with the map.

The ground is the game's own: the generator's heights blended across each 64 m zone the way the game blends them, plus every terraform players have made, coloured by biome from the chart, with water at 30 m. On it stands every object in the three 256 m chunks around you -- buildings, trees, rocks, bushes, ruins, boats -- each drawn with the game's model of it and placed, turned and scaled as in the world; a rock mined for its ore or for a base shows only the pieces still standing. The world's locations stand there too: the traders' camps, the Bog Witch's hut, crypt, cave and dwarven entrances, the boss altars, stone circles and runestones, as every game spawns them for itself. Who lives there is not drawn -- Haldor, Hildir and the Bog Witch are missing from their own camps -- and a part the game leaves to chance is drawn as its likelier outcome, so a location can differ in a detail from the one in game. Only chunks somebody has walked are sent at all; the rest stay dark, as on the map.

The players online (those the map shows) are drawn as themselves, as every other game draws them: the body they chose, their skin and hair colour, their hair and beard, and what they wear and hold -- helmet, chest, legs, cape, belt, what is in each hand and what is slung on their back -- facing the way they face, their name above. The server runs no animation, so they all stand in the game's idle pose, the first moment of it: no walking, swinging or sitting, and a cape hangs as it was modelled rather than in the wind. A player whose parts the library has not exported yet stands as a plain figure until it has.

Pull the overview out and the view reaches further in rings measured from the camera: the nine chunks under it with everything, while the camera is within 700 m; out to about the camera's distance (at most 3 km) the ground a little coarser and only what reads from afar -- player pieces two metres across or three high, big rocks, trees as their crowns or as cones; beyond, out to five times the distance (at most 16 km), the ground alone at 8 or 16 m a sample. From 4 km up the whole walked world of our copy is 1,019 chunks at 16 m, 575 KB over 1,019 requests, three seconds from the mod on a desktop. The explored mask says which chunks to ask for, fetches go nearest first six at a time, and whatever no ring wants is let go (at most 1,600 chunks of ground and 120 of objects). In the overview, W A S D or the arrows carry the circled spot over the land, E or Space raise it and Q or Shift alone lower it; Shift with a key goes faster.

The light follows the clock: the sun crosses the north 45 degrees up at noon and lies low and warm at either end of the day, so a slope turned from it falls into shade, and the air hazes what is far toward the sky's own colour, thickest along level sight lines and thin looking down from high. The overview stops down as it climbs, so the land sits below a brighter sky.

The model library

The first time the sweep meets a prefab, the mod exports it as a glTF model on the game thread, within export_ms_per_frame a frame. The dedicated server cannot read most textures, or about one mesh in eight, so a background thread then reads those straight out of the game's own asset files, once per game version, and the models that wanted them are exported again. The log says so:

WebMap: exporting models into .../map_data/models as the sweep finds them, 6 ms a frame
WebMap: extracting 199 textures from the game files in .../valheim_server_Data
WebMap: 199 of 199 textures extracted from 1 files in 5s
WebMap: 446 models to re-export with newly extracted textures
WebMap: model export done: 1001 prefabs in 15s; 507 with a model, 39 waiting on locked meshes, library 64.4 MB
WebMap: extracting 48 meshes from the game files in .../valheim_server_Data
WebMap: 48 of 48 meshes extracted from 1 files in 3s
WebMap: 45 models to re-export with newly extracted meshes
WebMap: model export done: 45 prefabs in 0s; 539 with a model, 7 waiting on locked meshes, library 71.2 MB

That was our world on a desktop: two and a half minutes from the first sweep to the last model. It starts when someone first opens a page that reads the sweep, and a restart exports only what is new.

A location is no network prefab: the game keeps its prefab apart and each game spawns it from a marker, so the mod loads it for the export and lets it go after, and a big one is exported over as many frames as it takes, its file written off the game thread.

A live player is exported in parts as players are seen: each body model, and each item a look wears, as it hangs on the body (ArmorBronzeChest@armor, SwordBronze@RightHand_Attach). The mod plays the game's own idle clip once on a copy of the Player's rig and keeps that pose; every part carries the Player's skeleton in it, the body and the garments skinned to it and a held or slung item as a node under its attach point, so the parts stand together wherever the viewer puts them. The viewer tints the skin and the hair and lays the body's chest and legs paint over the skin itself, so one part serves every player who wears it.

It all lives in map_data/models/, beside the worlds' own folders, since prefabs are the same in every world: index.json (what was exported), textures.json (what the models want), a <hash>.glb per prefab, tex_<name>.png per texture and meshes/*.bin for the extracted meshes. On our world that is about 73 MB -- 57 MB of models, 14 MB of textures, 2.4 MB of meshes -- and it survives restarts and updates of the mod. The models and textures are served to the 3D view; the meshes are game data and never leave the server. Delete the folder to export everything again.

Chat commands

  • !pin — a dot where you stand. !pin some text labels it.
  • !pin <type> <text> — types are dot, 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. Signed in (see below), 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.

Signing in with Discord

Signed in, members of your Discord server name places and portal hubs and place, move and edit pins from the map, and its admins change the mod's settings on the settings page (the ⚙ beside their name). To turn it on:

  1. In Discord, turn on Developer Mode (User Settings → Advanced), right-click your server and Copy Server ID.
  2. Stop the game server, put that id in discord_guild under [Server] in BepInEx/config/com.github.h0tw1r3.valheim.webmap.cfg, and start it again. A host that rewrites that file on every restart keeps it from you: put a line discord_guild<Tab><the id> in BepInEx/plugins/WebMap/map_data/settings.tsv instead, the file the settings page keeps.

Sign in then shows in the map's bar, and anyone in that Discord server can use it. The server's owner and members whose roles carry Discord's Administrator permission are the map's admins; discord_admin_role, a role id, makes that role's members admins too. No bot and no Discord application of your own are needed.

Sign-in goes through the project's sign-in service (valheim-proxy.hunterjsb.workers.dev) and its Discord application, which therefore learns who signed in to which map. It hands back a session signed for your Discord server and your map's address, good for a week, and the mod checks it with the service's public key: nothing secret is kept on your server.

The map's address. A session is good only at the map's own address, which the mod does not take from the visitor's request (anyone can write that): it is the server's public address, which the game learns from Steam or, on a crossplay server, looks up, at the map's port -- the log says WebMap: the game says its public address is ... -- or public_url. A map reached by a name, through a proxy, or on another port than server_port must set public_url (https://map.example.com); until one of the two is known, sign-in stays hidden from visitors outside. On the server's own machine or its LAN, the private address it is visited at works with no setting.

Signing everyone out. Sign everyone out on the settings page (sign_out_before) refuses every session signed in before that moment, yours too. It is the answer to a demoted admin, a member who has left, or a lost device: a session otherwise stays good for its week, whatever changes in Discord.

  • Plain HTTP. Most Valheim servers serve the map over plain http://, where the session travels in the clear: anyone on the network path can read it and act as that member, or admin, until it expires or everyone is signed out. If that matters, put the map behind HTTPS with a reverse proxy and set public_url.
  • Admins can move sign-in. discord_guild and discord_admin_role can be changed from the settings page, since some hosts rewrite the config file on restart and leave no other way: an admin's session, or a stolen one, can hand the map to another Discord server or role. Every change is logged, with who made it.
  • auth_url and auth_public_key — for running a sign-in service of your own: its base URL, and the public half of its RSA key as <modulus base64>.<exponent base64>. Empty is the project's own. They are read from the config file only, never set from the site.

The audit log and the chat relay below are another matter: they still want a bot of your own.

Discord

Set discord_bot_token (from the site's settings page, or the config) and the mod talks to Discord on its own, over the bot API rather than a webhook:

  • The audit log. Every name, pin and setting change made from the site is posted to discord_log_channel as it happens (Discord.Tell, from the same code that already logs it to the console). The server going up and down, joins and leaves are unaffected — they keep going out the existing discord_webhook.
  • Chat, both ways. With discord_chat_channel set and chat_relay on (the default), in-game chat is posted there as name: text, and the mod polls the channel every three seconds for the reverse: a human's line (bots, webhooks and the mod's own posts are skipped) is spoken in game as [Discord] name: text, by guild nickname where the member has one. A restart never replays history — the poll starts from the newest message it finds — and at most five messages a poll are spoken, so a flood on the Discord side cannot flood the server.
  • The settings picker. Once a token is set, discord_guild and the two channel settings offer a dropdown on the settings page instead of a bare id field (GET /discord/guilds, GET /discord/channels?guild=, for admins only, as /settings); without a token they're just a plain field.
  • Discord's bot API needs the Message Content privileged intent turned on for the chat relay to read anything (Discord blanks content on every message otherwise): the app's page, Bot → Privileged Gateway Intents → Message Content Intent.

An empty discord_bot_token turns all three off; nothing here touches discord_webhook.

Configuration

Standard BepInEx config, plus:

  • render_size — pixels across the world render, default 2048. Same area as texture_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 at texture_size, where extra resolution buys nothing. A map.png of 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.

For the 3D view, under [Models] (the host rewrites the config on restart, so the defaults are what runs):

  • export_models — export each prefab as a glTF model into the library, default true. Off, the 3D view has ground and nothing on it.
  • extract_textures — read the models' textures out of the game files, default true. Off: flat colours.
  • extract_meshes — read the meshes the engine keeps locked out of the game files, default true. Off: those models are drawn as boxes of their size.
  • export_ms_per_frame — game-thread milliseconds a frame the export may take, default 6.
  • texture_max_size — longest edge of an extracted texture in pixels, default 512.
  • object_categories — what the 3D view is sent, of piece,other,rock,bush,tree, default all five.

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 a signed-in session (Authorization: Bearer) or, from a proxy, X-Announce-Token, which 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 a session or X-Announce-Token as /names; the member owns a new pin and is logged for every write
/auth/login ?to=<a page of this map>: 302 to the sign-in service with this map's Discord server and address; 400 for a to anywhere else. 404 while discord_guild is empty, or while the map knows no address of its own the caller could be at (see The map's address)
/auth/me the signed-in member, {"id","name","avatar","admin"}; 401 without a valid session, 404 as /auth/login
/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
/settings GET the mod's settings as the site shows them; POST {"key","value"} sets one (empty value: back to the config's); needs an admin's session, or X-Announce-Token with X-Admin: 1
/discord/guilds the bot's guilds, {"guilds":[{"id","name"}]}, for the settings picker; same gate as /settings
/discord/channels ?guild= a guild's text and announcement channels, {"channels":[{"id","name"}]}; same gate
/pieces every placed piece as [prefab, x, z, yaw, lit, by] against a table of prefab footprint and colour and a players list: lit is a torch's, fire pit's or hearth's fuel, 1 while it burns, 0 once out, -1 on a piece that does not burn or is not known; by who built it, an index into players by the name the stats know them by, -1 for a builder never seen online (JSON, a few hundred KB for a busy world)
/locations the world's locations -- altars, crypts, caves, camps, ruins, wrecks, runestones and the rest, by the game's prefab name -- where the ground is walked: {"locations":[{"kind","x","z"}],"count"}, built once a sweep (JSON)
/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 (by biome, the sea as Ocean), portal hops, pins, standing pieces/portals/ships, graves, kills, trees felled, rock pieces broken, and gear: seconds with each kind of thing in hand and in each weight of chest armour, the set last worn, hits on creatures by kind; kitchen: dishes cooked and burnt, meads brewed, honey taken, items smelted and each product's count; and look and yaw, how the player was last seen, as /state carries them; at the top, the world's kitchen and its busiest stations (JSON)
/players, /messages live state (JSON)
/pins (GET) the pins, a line each: placer,id,type,owner,x,z,text (CSV)
/config what the page starts from: world name and start, zoom, texture and pixel size, update interval, explore radius, messages kept, always_map, always_visible (JSON)
/ (WebSocket) players, messages, pin, rmpin, ping and reload frames as they happen; the bundled pages poll /state instead
/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, the structures and forest stats -- plus a content revision per layer (rev.fog, rev.forest, rev.structures, rev.pieces, rev.chart, rev.trails, rev.features, and for the 3D view rev.objects, rev.height, rev.models) so a viewer fetches a layer only when its picture changed; pass the revision as ?v=. time is the game's clock: {"day", "frac"}, the fraction of the day the sun goes by (0.25 sunrise, 0.5 noon, 0.75 sunset). A player the map shows carries yaw (degrees clockwise from north) and look: model (the body), skin and hair (the colours, 0..1), slots (the prefab in each slot) and parts, the library parts to draw, body first
/height ?cx=&cz=[&step=], a 256 m chunk (cx = floor(x / 256), cz likewise): (256/step + 1)² little-endian int16, decimetres of world height, step metres apart (1, 2, 4, 8 or 16: 257 a side down to 17; default 1, each step cached on its own under the same revision); row 0 is the south edge (z = cz*256), column 0 the west, both edges included so neighbours share a seam. Terraforming included; water stands at 30 m. 404 for a chunk nobody has walked; the chunk's terraform revision in X-Rev
/objects ?cx=&cz=, every visible object in the chunk as OBJ2, little-endian: 'OBJ2', u32 count, u32 prefab count, i32 prefab hashes, then 44 bytes an object -- u16 prefab index, u8 flags (1 = player-built, 2 = pieces mined off), u8 pad, f32 x y z, f32 rotation quaternion x y z w, f32 scale x y z (Unity's frame, y up, z north) -- then the mined rocks: u32 count, and per rock u32 object index, u16 bit count, bits/8 bytes, bit i set when its hit area i is gone. (OBJ1, the first cut, was the same without the flag and the table; the page reads both.) 404 for a chunk nobody has walked; its revision in X-Rev
/prefabs the model library's index: per prefab hash, its name n, category c, whether it has a model m and its version v, bounds b, and for foliage the canopy bounds k, leaf texture kt and tint kc; for a rock mined in pieces its kind rk (5 MineRock5, 1 MineRock) and pa, the hit area of each glTF primitive in order (-1 none, -2 MineRock's whole-rock model); for a live player's part (category rig, named as a look's parts) the chest and legs paint it lays over the skin, ct and lt (JSON)
/models/<file> a model (<hash>.glb, the hash as eight hex digits) or a texture (tex_<name>.png) from the library; cacheable for a day, ETag the file's time
/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.

Kills, trees and rocks. The server runs no creature, tree or rock -- the nearest player's game does -- so it counts what passes through it:

  • kills -- a creature that fell within ten seconds of a player's hit, for the last player seen hitting it. A tamed creature counts for nothing, and one that despawns is nobody's kill.
  • trees -- a tree felled; its logs and stump do not count again.
  • rocks -- each piece broken off a rock or an ore deposit, and a small rock broken whole.

A hit reaches the server only on its way to another player's game. A player alone in an area owns everything around them, and their own hits never leave their game, so when no hit was seen the owner is credited instead: with a creature whose own record lists them among the players who hit it, a tree felled within 16 m of them, a rock piece within 32 m. A creature the owner kills with a single blow can go uncounted, since its record may not reach the server before it falls.

Gear. A player's ZDO carries what every other game draws them holding and wearing, so once a second, while alive and out of bed, the server adds a second to:

  • hand -- the family of what each hand holds, sorted by the item's own skill and type: bow, crossbow, staff, shield, knife, sword, axe, mace, spear, polearm, hammer, hoe, cultivator (the scythe too), pickaxe, torch, fishing, tool for anything else held (a tankard), none with both hands empty or sheathed (fist weapons are the game's own unarmed). A sword and shield is a second of each. twohanded counts on top of the family, for a two-handed melee weapon.
  • armor -- the chest piece's weight, from its movement penalty: light none (leather, troll, lox, fenring, Askvin), medium up to 3.5% (root), heavy more (bronze, iron, wolf, padded, carapace, flametal: 5%), mage whatever speeds eitr (eitr-weave), none bare.

diet is seconds by what the food behind the bars leans to: hearty (health half again the stamina), quick (the other way), eitr (forty or more of it, one eitr food), balanced, none (under thirty of anything). The server never sees the foods, only the max health they set and the stamina and eitr they refill to when the player stands still, so a slowly fading peak of each stands in for the maximum; food is the latest hp, st and eitr above the base 25 and 75.

worn is the latest set: right, left (a sheathed weapon still counts), chest, legs, helmet, shoulder, and the two back slots rightBack and leftBack, by prefab name. hits are colour, not a record: a hit on a creature counts only when it passes through the server on its way to another player's game, never one on a creature the hitter's own game runs, which alone in an area is all of them. Each is melee, ranged or magic (a staff's), and backstab counts on top when the weapon has a backstab bonus and the creature was not yet alerted.

Kitchen. Nobody's inventory reaches the server, but the stations do: every prefab with a CookingStation, Fermenter, Beehive or Smelter component (the fires, the iron cooking station, the stone oven; the fermenter; the hive; the smelter, blast furnace, kiln, windmill, spinning wheel and eitr refinery) is found by the sweep, or by the first slot filled on it, and its ZDO is read every five seconds. A player's kitchen counts:

  • cooked -- a slot gone done, for whoever put the item on: the station's owner broadcasts every slot it fills, and a player who is not the owner asks the owner first, through the server, so the placer is known exactly. burnt instead when it is left on the fire to burn (a dish counted as cooked moves over).
  • brewed -- the meads a fermenter drops when tapped, for whoever filled it.
  • honey -- honey taken from a hive.
  • smelted -- each item a smelter makes from its queue: bars, coal, flour, thread, eitr, for whoever loaded that ore.
  • dishes -- the cooked dishes, meads and smelted items by prefab name.

A fermenter's filler, a hive's taker and a smelter's loader is the nearest player within 8 m. A slot filled before the mod was watching counts for nobody. The document's own kitchen holds the world's cooked, burnt, brewed, honey and smelted, credited or not, and stations, the forty busiest by prefab (kind) and place (x, z), each with its cooked, brewed, smelted and honey. The cauldron and the preparation table leave no trace, so of bread and pies only the oven's step counts.

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, .spot (a spotlight: one figure, a line of context and a small picture from MapCore.pic -- a roster strip, a share ring, labelled bars), .cls .mini .radar .viking (a player as js/card.js draws them), 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 (and, for the 3D view's WebGL, --use-angle=swiftshader --enable-unsafe-swiftshader), 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, ground and objects in 3D at the world's start, hubs and dial lines, a card per player, the plan loading, the tour landing through a resize, a stopped one staying stopped and a player's card turning in it until it goes. 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.

A fresh world has nothing in it until a player walks there -- the server builds zones only around players -- so the 3D view and the sweep want a real world. Copy one in (Valheim 1.0 saves a world as a folder, worlds_local/<name>/) and run it under its own name, with its fog.png so the same ground counts as walked:

VALHEIM_TEST_WORLD=Mothership ./testserver.sh up
podman cp Mothership valheim-test:/valheim-saves/worlds_local/
podman cp fog.png valheim-test:/valheim/BepInEx/plugins/WebMap/map_data/Mothership/fog.png
podman exec valheim-test chown -R steam:steam /valheim-saves/worlds_local /valheim/BepInEx/plugins/WebMap/map_data
podman restart valheim-test

Licence and credit

MIT where applicable.

CHANGELOG

Changelog

2.16.1

  • The map page's script is split into files with one global each, as map-core.js and js/card.js are: js/tour.js the cinematic, js/street.js the 3D mode and the pegman, js/spot.js the spotlight (a turn the players and World pages can hand their own figures to) and js/layers.js the Layers card, each handed what it reads by one init; the page keeps the view, the live data, the markers, the place card and the pins. Nothing a player sees or does changes. The two checks that cut into 3D build their place first, by the view's own measure, and wait on the tour's beat, where they had timed out under software rendering.
  • The pages share what each had a copy of, and again nothing a player sees or does changes. One spotlight, js/spot.js, turns the map's figures, the World page's records and a player's figures, the last as the strip whose rules are now site.css's. The helpers two pages defined are map-core.js's once -- a figure and a distance, a tally's age, the walked world's share and box, a canvas sized to its stage, the scale bar, the names by revision -- or js/card.js's for a player's kitchen, and the plan uses the core's geometry and view rather than copies of its own. Every call to the server goes through MapCore's one client: one API base, one error (the server's own words or the status, with the status on it), and fresh reads unless a versioned one asks for the browser's cache. The page check measures the 3D view across a tour over two cycles after a warm one, where a cold first cycle had failed it now and then.
  • The pages' styles follow their scripts: what several pages drew alike is site.css's once -- the bosses board and danger by biome (their markup map-core.js's once, for the World page and a player's), the page column, a list of cards, a small button, the big pentagon's labels, a legend's heading -- each page's <style> keeps only its own, grouped, with rules nothing matches gone, and a class name two pages used for different things is two names now. A distance in metres or km and the minute's re-read are map-core.js's once. Nothing a player sees changes but one leak: a signed-in name in the Portals page's bar had picked up that page's own name style, and now looks as it does on every other page.
  • The map page keeps what it knows of the world in one object, WORLD, where some twenty-five loose globals held it: the five-second tick and the layer loads write it, and the markers, the place card, the pins, the feed, the status box, the spotlight, the tour, the 3D view and the Layers card only read it. One tick takes the state in, then draws what it changed in the order it always has -- the status, the 3D view, the tiles and markers, the feed -- then asks for the layers whose revision moved; the page's top-level names go from 93 to 65. Nothing a player sees or does changes.
  • The status box at the head of the map's sidebar keeps its card once the status is drawn -- the background, the border and the edge of light it shows while the page loads, and the card's ink for its line. Drawing the status had set the box's whole class list, so at the first tick the card went, the line turned the links' gold and the sidebar under it rose by the border's two pixels.
  • The planning board's colour picker has a class of its own, .colour, where it shared .sw with the legend's swatches: the legend's swatches lose the faint ring the picker's rule gave them, and look as the map's legend does. The picker is unchanged.
  • The viewer has a lint, npm run lint (ESLint, dev only, at the repo root), and CI runs it before node --test: every script and the pages' inline ones are held to what the cleanup left -- no name read that was never declared (a window property such as name or status included), none declared and never read, no var, and a classic script defines its one global and nothing else -- with no rules of style. It found a count the 3D view kept and never showed, and three lets never reassigned; nothing a player sees changes. The mod's build treats a warning as an error, so it keeps the none it has.
  • tools/bench.mjs measured the builds' view through PIECES, a name the state object retired: the probe threw in the page, and the bench printed undefined for the pieces and empty figures for its base view. It reads WORLD.piecesShown now.

2.16.0

  • Sign in with Discord on any server running the mod: set discord_guild to your Discord server's id and Sign in appears on the map, through the project's sign-in service and its Discord application, so no operator makes an app or a bot for it. Members name places and pin the map from the browser; the server's owner, its administrators and discord_admin_role change the settings. The mod checks each session with the service's public key -- signed for this Discord server and this map's address, a week -- at /auth/me and on every write, where the proxy's token keeps working as before. The map's address is never taken from the request: it is the server's public address at the map's port, as Steam or the crossplay lookup gives it, or public_url, which a map reached by a name or through a proxy must set; the address visited at counts only on the server's own machine or its LAN. /auth/login returns only to this map. Sign everyone out on the settings page (sign_out_before) refuses every session signed in before it, for a demoted admin or a lost device. auth_url and auth_public_key (config file only) point sign-in at a service of your own. The announce token is compared in constant time.
  • The portal atlas draws the dashed dial line for a tagged, unlinked gate whose own hub is the only one with a gate to spare: it goes to the nearest other hub, where before it was left out.
  • The upstream fork's Cake build path is gone -- build.cake, build.sh, the Dockerfile and entrypoint.sh, docker-bake.hcl, build/AssemblyPublicizerTool.cake, a VS Code task that ran Cake, and 5 MB of tool binaries that had been committed under tools/; the build publicises the game assemblies itself, and libs/README.md now says so (and names the library folder correctly).
  • BepInEx logs the mod's real version at load, where it had said 2.7.1 since 2.7.1: the build takes it from manifest.json, the one place a release bumps.
  • A push builds once: ci builds, packages and tests every push and pull request, and the release workflow runs on v* tags alone, on the same .NET SDK.
  • The write routes are under test, over real HTTP on a loopback port: no credentials, the proxy's token and the X-User it names, X-User and X-Admin without it, a member's and an admin's session, and one for another Discord server or another map. The server's broadcast timer now starts when it listens rather than when it is built, which is what let a test build one; before, every tick until the server listened threw.

2.15.0

  • The tour grows to about twenty stops a cycle, drawn through every kind of place before any repeats, and remembers what the last cycle showed so the next differs: every boat and cart, the loneliest gate, the newest death and the oldest grave, each builder's own build ("Rurik's stronghold", "Withers' outpost"), the busiest kitchen, a ridge flown along its peak line, a bay's shore flown from the water, a dock or a beach looking out to sea, where people go under the trails, and the world's own locations -- boss altars, burial chambers, troll caves, sunken crypts, fuling villages, dvergr outposts, infested mines, tar pits, ruins, wrecks, runestones, stone circles, Hildir's hideouts -- circled as tightly as their model allows. In 3D a stop is seen by a move of its kind: a low sweep, a crane from a man's height to an overview, a dolly in from far off, a land at a slant of its own, a flight across a settlement; or at eye height, in a base's yard with a slow pan, walking through the settlement or along the shore, out to sea from a dock, down from a summit, round the gates of a hub, a stand chosen to see out rather than into a wall or a rock -- each beat lit at a time of its own (a land from afar or a coast at the low sun, the rest by day or toward dusk) and the live clock given back after. The map lands at a zoom of the stop's kind, drifts across the biggest builds at plan zoom and sometimes pulls slowly back. A caption carries a strip of figures matched to the place, and every third or fourth stop is one of the world's figures over a far drift of the map. The 3D view builds the next place while the current one plays -- its chunks, objects, models and far rings -- and the cut waits for its near ring, holding the map a few seconds and leaving a place still loading on the map rather than cutting into a half-built world; only the places shown and coming next are held, and a chunk's fetch no longer holds its turn in the queue while its models load. View3D gained setOrbit, dolly, crane, pan, stand, walk, still, prefetch, ready and clearance. The mod tells the locations at /locations, in walked ground only and built once a sweep, and /pieces rows carry a sixth field, by, the builder's index into a new players list (-1 for one never seen online), the fifth now -1 on a piece that does not burn.

  • The 3D view's light and air. Everything it draws is hazed toward the sky's own colour with distance, an exponential height fog summed along each sight line (1/3,800 a metre at the sea, thinning by e every 1,200 m up): thickest along level lines low down, thin looking down from high, so from 2 km up a continent fades into the distance while the ground under you stays clear; contrast and colour fall with it, the mist over unwalked sea included, and the fog at the edge of what is loaded takes the sky's colour too. The sun carries the day: it crosses the north 45 degrees up at noon (65 before), at 3.4 (2.6) over a cooler sky fill, and the environment the ground is lit by now sees dim ground below the horizon rather than more sky, so a slope turned from the sun shades; dawn and dusk are low and warm. The overview stops down as it climbs, to 0.57 of the exposure under a high sun and 0.78 at dusk, so the land sits in the mid-tones below a brighter haze; a standing view keeps its exposure. A chunk's edge normals span the coarsest step meeting there and are taken again as its neighbours load, so no seam or ring boundary shows in the light. setClock(frac) eases to a time of day and holds it over the live clock for a beat of the tour; setClock(null) gives the clock back. On the far view in a headless Chrome: the same 4,937 draw calls, and no slower, 34 ms a frame against 37.

  • Kitchen deeds: cooking, brewing, honey and smelting, per player. The server never sees a bag, but it sees the stations: the sweep hands every cooking station, oven, fermenter, hive and smelter (kiln, windmill, spinning wheel and refinery too) to a poll that reads their ZDOs every five seconds. A dish done on a slot is the placer's -- known exactly, from the owner's broadcast of the slot and a cross-owner request passing through the server -- and burnt instead when left to burn; a fermenter's tap is its filler's meads; a hive's honey and a smelter's loading go to the player standing within 8 m; each smelted item is its loader's. /stats/players gains a kitchen per player (cooked, burnt, brewed, honey, smelted and dishes by product) and one for the world with its busiest stations, kept in stats.tsv (c, f, k and s lines; an older file reads as none). The players page has a Kitchen group and its spotlights, a kitchen trade -- Cook, Baker, Brewer, Smith, from fifty things made -- after Homesteader and over Builder when it is the larger share, and cooking and smelting on the Harvester side of the pentagon; the map's spotlight counts the meals, and the World page has a Meals tile, a Kitchen line and the busiest kitchen among its records. Pages read the same as before from a mod without it.

  • The tour shows who a stop is about. At someone online, the latest death or a grave, their card stands at the side of the stage, over the map and the 3D view alike: the name, the class with its reason under the pointer, "online now" and where or when they were last seen, the pentagon and the four figures of their roster row, and their Viking turning as on their page (a phone keeps the figures and drops the Viking). One renderer serves every card, loaded with the first look; the card's turntable stops and its rig is freed as the card goes, and none of it outlasts a stopped tour. The class, the pentagon, the figures and the turntable moved out of the players page into js/card.js (PlayerCard), which both pages share; the players page draws exactly as before.

  • A debugging and optimisation pass over the night's work, each change measured first. /state no longer carries the per-player tallies (a fifth of its bytes, rebuilt every request while anyone was on, read by no page: /stats/players has them). The chat dedupe hashed every RPC sent to everybody, every damage number among them; it now looks at chat alone. A fault in the second's player snapshot is logged once instead of silently stopping looks, gear or the stats saves for the run, and a pin placed from the site counts at once. A 3D chunk holds its objects or their bytes, not both (53 MB less on our world), and "model export done" names its longest frame and slowest step. The World page's minute takes 6 ms, not 235. The tour lands on its spot when the window is resized in flight, a stopped tour's last stop no longer stops the next tour circling or undoes layers shown since, and its fold of the sidebar is not kept as the site's choice. The 3D view frees a departed player's name tag and, two minutes after you leave it for the map, lets its world go (a 64 MB heap and 1,800 GPU geometries down to 9 MB and 17). Each page asks /auth/me once, not twice.

  • A player's page draws them: above the pentagon, their Viking as the 3D view draws them, turning slowly on the map's ground colour under a key light (a still with reduced motion) -- the live look while they are online, the one the server remembers once they have gone, captioned "online now" or "as last seen"; a player with no look yet shows no card. The mod keeps each player's latest look (the JSON /state carries) and yaw in stats.tsv as an l line, taken as /state's players are built and stored only when it changes; a player hidden from the map is kept too, since a look says nothing of where they are. /stats/players carries it as look with yaw; an older file reads as none. three.js loads with the first look shown. The rig builder moved out of the 3D view into js/rig.js (RigBuilder), which both pages share.

  • Live players in 3D as themselves: the body they chose (the game's two), their skin and hair colour, their hair and beard, and what they wear and hold -- helmet, chest, legs, cape, belt, trinket, what is in each hand and what is slung on the back -- facing their way, their name above. The server runs no animation, so the mod plays the game's own idle clip once on a copy of the Player's rig and keeps that standing pose; the body and every garment are exported as skinned glTF (joints, weights, inverse binds) over the Player's skeleton in that pose, and a held or slung item hangs from its attach point as a node under its bone, so everything a look names stands together where it belongs. A look resolves as VisEquipment attaches it: a helmet hides the hair or swaps it for its hat cut, a sheathed bow goes to the bow's place on the back and a torch to its own. Skin and hair are tinted in the viewer, the body's chest and legs paint (the tunic or trousers a garment paints on the skin) laid over the skin there too. /state players carry yaw and look (the body, the colours, each slot's prefab and the parts to draw); the library gains a part per body and per worn item (category rig) as players are seen, about 20 KB each. A player is dressed again only when their look changes, from parts baked once and shared, so six players cost a few dozen draws. The index format is unchanged. The mod now builds against the game's UnityEngine.AnimationModule.dll too.

  • The world's locations in 3D: the traders' camps, the Bog Witch's hut, crypt, cave and dwarven entrances, the boss altars, stone circles and runestones. The game networks a location only as a marker (a LocationProxy) and every game spawns the rest itself, so the marker goes out in /objects as the location's own prefab, at its place and turn, and the model library exports the location from the game's list of them: what every game spawns for itself, without who lives there (the traders are not drawn), what is networked on its own (chests, beehives, spawners, the ruins' and houses' own pieces -- already drawn from their own records), lights, particles, the traders' force fields and a dungeon's rooms (kept 5000 m up), each part left to chance as its likelier outcome, merged by material into a dozen draws. On our world the sweep meets 128 kinds of location; 36 have a part of their own to draw, the rest are wholly networked. A big location exports over as many frames as export_ms_per_frame needs, its file written off the game thread. The index format is unchanged, so nothing already in the library is exported again; the locations join it as the sweep meets them. The mod now builds against the game's SoftReferenceableAssets.dll too.

  • What each player holds and wears, for the site to tell an archer from a builder: gear in /stats/players, kept in stats.tsv (an older file reads as none yet). Once a second, alive and out of bed, the equipment the player's ZDO syncs adds a second to the family in each hand (bow, sword, hammer, pickaxe ... none, plus twohanded for a two-handed melee weapon) and to the chest's weight class (light, medium, heavy by movement penalty, mage for eitr-weave), all sorted by the item's own data so new gear sorts itself; worn is the latest set by prefab name. Hits on creatures that pass through the server count as melee, ranged or magic, with backstab on top when the creature was not yet alerted -- partial, since a hit on a creature the hitter's own game runs never reaches the server.

  • Players have a class in three parts. The first word is a compound: what is fought with gives the prefix (battle for a two-hander, blade, shield, bow, knife, spell) and the body gives the root (mage, knight, warrior, rogue, brute), read from the clothes and the food -- the server never sees the foods, only the health, stamina and eitr they add, so the mod tallies a diet (hearty, quick, eitr, balanced, none) beside the armour and reports the latest food-borne figures. The two make Battlemage, Spellsword, Spellguard, Nightblade, Arcane Archer; Templar, Champion, Paladin, Marksman, Executioner; Warlock, Reaver, Fighter, Warden, Ranger; Enchanter, Berserker, Duelist, Skirmisher, Archer, Assassin; Shaman, Bruiser, Guardian, Hunter, Cutthroat; and the weapon itself colours a few: Arbalist, Vanguard, Huscarl, Hoplite, Raider. Before any gear is seen, twenty deaths at a worse pace than half the roster say Berserker. The second word is the trade, from the deeds: the roster's top builder is the Architect, a builder who also mines an Engineer, who also fells a Carpenter, who also farms a Homesteader; the roster's Slayer; Builders, Miners, Lumberjacks, Farmers; a roamer an Explorer or, a third of it at sea, a Seafarer; an Angler; a Wayfarer of the portals. Any part may be missing. The title sits on the roster row (under the name when the list is narrow) and on the player's page with its reason spelled out part by part, a Gear group shows what they were last seen wearing, and the spotlight turns through it. Time in hand is the working evidence: the server sees blows landed only on creatures another player owns, and skills never leave the character file. Faint until ten minutes have had something in hand; the worn set carries the two back slots so a mage building all night stays a mage.

  • A continent from 2 km², not 4: this world's lands top out near 4.6, and the home lands of two to three are what people call continents. A land that grew into one keeps its stem (Ragnsey is Ragnsland), and a name given while it counted as an island still holds.

  • A World page, second in the nav: the world at a glance (its day, explored share, what stands, what fell, the gates, the boats, the names, the deaths and the graves), the bosses, and its records -- largest continent and island, most and least explored land, tallest peak, longest river, biggest lake, widest bay, most built, deadliest and most gated place, the farthest build, the northern- and southernmost walked ground, the oldest grave, the latest name -- each with the way to the spot on the map and in 3D; danger by biome for everyone together; and every land in a table that sorts by area, walked share, pieces, gates, deaths or peak. A spotlight turns through the records against their runners-up. The tour visits the records too: the tallest peak circled, the biggest lake from above, and the longest river flown along its own course. The nav reads Map, World, Portals, Players, Plan.

  • The tour has more to visit: the pins players placed, the places they named, a mountain, a bay, a walked corner with no build near it, and the sacrificial stones at the centre; a dozen stops a cycle with no more than two of a kind.

  • A cinematic, for a screen left open: the ▶ among the map's buttons (or a link ending in #tour) starts a tour of the world's places one after another -- who is on, the portal hubs, the biggest build, where people die, the graves, the traders, a boat, the largest lands -- each flown to on the map with the layer that makes it worth seeing, held, then circled slowly in 3D where the ground is walked, a land from kilometres up. A caption names the place and why. Any touch ends it and puts the layers and the sidebar back. The 3D view gained setSpin and setOrbitDist for it. A stop switches its layer back off on the way out; the way between stops is flown high, where the fog reads as the world's shape rather than a black wall, and a spot still under fog is not flown to; in 3D the sea nobody has sailed is mist, so from far up water and the unknown read apart.

  • Back to one face. The brand, a page's title and the map's capitals are the site's own sans in tracked capitals again; no font is served with the pages any more.

  • The players page reads as a roster: one row per player with the four figures people compare, and a click opens the player's page beside it while the roster steps to the left as a column (on a phone, one or the other, with a way back). Sort and Show are dropdowns -- everyone, online now, seen this week, gear still out. The bosses wear icons of their own, in the order they fell, the standing ones dim. An online player's page says where they are in the world's own names, with a link to the spot. Each player has a pentagon -- Explorer (km, portal hops and km at sea together), Builder (pieces), Survivor (km per death), Slayer (kills), Harvester (trees and rocks together), each side a share of the roster's best, square-rooted -- small on the row and labelled on the page. Picking a player slides the page open, and its figures read in five groups: Survival, Journeys, Deeds, Building, Presence. A spotlight beside the pentagon turns through the player's figures one at a time -- where each sits among the roster, a share of everything, the closest call, the streak against the walk since -- with a small picture for each; a hover holds it, a click moves on. The map's sidebar has one for the world: how much is walked, who built the most of what stands, the boats by kind, the gates linked, the bosses down, where people die, the graves out there, the names given, the roster's farthest, deadliest and loudest, and the latest death. The sidebar's tiles are gone with it: the status row carries the explored share and the count online, small, and the felled trees and the boats afloat turn in the spotlight. The tab wears the world's name, the map plain and the other pages as "world · page".

  • Kills, trees felled and rocks broken, per player: kills, trees and rocks in /stats/players, kept in stats.tsv (an older file reads as none yet). The server runs none of it, so it reads what passes through it: a hit sent to another player's creature, tree or rock carries its attacker, and the fall -- a destroy, or a piece breaking off a rock -- follows within ten seconds. A hit on something the hitter's own game runs never reaches the server; then the owner is credited, with a creature whose own record lists them among its attackers, a tree within 16 m of them, a rock piece within 32 m. Tamed creatures, logs and stumps count for nothing, and a creature that despawns is nobody's kill.

  • Metres at sea show at last: the tally had kept them all along as biome class 0, and the players' JSON skipped that class. biomes now carries an Ocean entry.

2.14.0

  • Discord moves into the mod, and talks back. discord_bot_token (with discord_guild, discord_log_channel, discord_chat_channel) posts what admins do on the site -- a name, a pin, a setting -- straight from the mod, so the Worker no longer relays it. chat_relay (on by default) carries in-game chat to discord_chat_channel as name: text, and reads it back every three seconds, speaking a human's line in game as [Discord] name: text (bots, webhooks and the mod's own posts never echo back; at most five a poll, so a flood in Discord cannot flood the server; a restart never replays history). Joins, leaves and deaths keep going out the existing discord_webhook. settings.html offers a picker for the guild and its channels (GET /discord/guilds, GET /discord/channels?guild=) once a token is set, and falls back to a plain field otherwise; the token itself stays a masked field.
  • Admins, and settings from the site. A signed-in member who owns the Discord guild, holds a role with its administrator permission, or holds the role the deployment names is an admin: the bar shows a gear, and settings.html lists the mod's settings with what each does and whether it applies live or at the next restart. The mod keeps what admins set in settings.tsv beside the map data and lays it over the BepInEx config, which the host rewrites on restart. GET/POST /settings behind the shared token and an X-Admin the Worker sets after checking the member's roles again.
  • One face of the site's own: Marcellus SC, served with the pages (and by the mod: its static files know woff2 now), for the brand, a page's title and the map's capitals -- continents, islands, ranges. One weight, so the map's tiers rank by size and spacing. Everything else keeps the system sans. The scene repaints once the face has loaded.
  • A hub opens close up on the portals page: click its name on its card or on its panel in the diagram and an overlay shows the render with the base's floor plan over it, each gate drawn where a player walks to it (linked bright, tagged-and-waiting amber, free dim), house pins naming the buildings, and a strip of the gates -- click one to light and centre it. Going through a gate from a card, a panel or a far place's node lands you in the far side's close up with the gate you arrive at lit, and a lit gate's mark takes you on through. Map and 3D from the header, Esc closes, #in=x,z&at=<gate> links in. The ⌖ at the end of a card's row still opens the map on that gate.
  • settings.html reads in groups -- the map, players, chat and Discord, the 3D view, the mod -- and the chat relay keeps one poll on the wire at a time (a slow Discord call once let a line be spoken six times).
  • On the first start after this update the mod exports every prefab the world holds for the 3D view: a few minutes on the game thread's spare time, a burst of memory, and about 70 MB beside the map data. export_models = false skips it, and the 3D view then has ground and nothing on it.
  • The plan page draws the place names too, as a reference layer of its own.
  • Every write from the site -- a name, a pin, a setting -- answers with the line it logged (superseded below: the mod itself posts that line to Discord now).
  • The world in 3D, as a mode of the map entered the way Street View is: drag the figure from the map's buttons onto walked ground (on a phone, tap it and then the map), pick 3D under View in the Layers card, press See in 3D on a place or a pin, or follow a #3d=x,z link. You stand there at eye height looking north: drag to look, wheel or pinch to zoom the eye, WASD or the arrows to walk, a click on the ground to walk there, ⇅ for an orbit camera over the spot, ☼ for the light and what is shown. Map or Esc puts the map back where you stood. three.js and the renderer load on the first step into 3D and never with the map. Ported, with thanks, from f00d4tehg0dz/valheim-webmap (MIT): the prefab exporter and glTF writer, the model library, the texture and mesh extractors that read the game's own asset files (the dedicated server cannot read its textures, nor most meshes), the per-chunk world objects, the instanced viewer with its canopy billboards, and the sky. three.js r180 is vendored under web/vendor/three.
  • The ground under it is ours: GET /height?cx=&cz= answers a 256 m chunk (cx = floor(x / 256)) as 257 x 257 little-endian int16 decimetres of height, a metre apart, row 0 the south edge and column 0 the west, both edges included. It is built as the game builds its heightmap -- the generator's height for each 64 m zone's corner biomes, blended by smoothstep -- plus every zone's terraforming, decoded from its _TerrainCompiler; trees sampled against it stand within 4 cm of it.
  • GET /objects?cx=&cz= answers a chunk's objects (OBJ1: prefab, position, rotation, scale, player-built), GET /prefabs the model library's index and /models/<file> a model or texture from it. /objects and /height answer 404 for a chunk nobody has walked, and nothing about one is even recorded. /state carries rev.objects, rev.height and rev.models, and time (the game day and the fraction of it the sun goes by). New config under [Models]: export_models, extract_textures, extract_meshes, export_ms_per_frame, texture_max_size, object_categories.
  • The mod serves subfolders of web/ now (the vendored three.js keeps its layout), and gzips text and the 3D chunks for a client that takes it.
  • Mined rocks in 3D show only what is left of them. A rock mined in pieces (MineRock5, the *_frac rocks, and MineRock) keeps a health per hit area in its ZDO; /objects is now OBJ2, carrying a mask of the areas gone per rock, and /prefabs says which hit area each part of a rock's model belongs to (rk, pa; the library's index is format 6, so the first start re-exports every model, about a quarter of a minute). The rock the base was cut into at -296, 328 now stands as the two pieces the miners left.
  • The overview flies: W A S D or the arrows carry the circled spot over the land the way the camera faces, E or Space raise it, Q or Shift alone lower it, and Shift with a key goes faster. Street view keeps its own keys.
  • Whole continents in 3D. Pulled out, the view loads in rings from the camera -- everything close in, ground and the big things (pieces, boulders, trees as crowns or cones) out to a few kilometres, coarse ground to sixteen -- asks only for walked chunks, nearest first, and lets go of what no ring wants. /height takes step (1, 2, 4, 8 or 16 m between samples, each cached on its own), so the far rings cost a few hundred small requests. The haze and the camera reach kilometres, and the sea runs on to the horizon.

2.13.0

  • The land has names. Once per world the mod reads the generator's own geography -- landmasses, lakes, bays and mountain ranges from a sampled grid of biome and height, rivers from the generator's river list -- and gives each an Old Norse name from the seed: Morland, Stenarey, Frostfjell, Eikvatn, Nordsvik, Langará. /features lists them (kind, where the name sits, extent, a range's peak, a river's course), rev.features in /state says when they changed. The map sets them the way an atlas does, once the fog has lifted somewhere on the place; a Names detail switches them off.
  • Naming a place. POST /names with {"id","name"} gives a place a name (an empty name returns the world's own), guarded by the announce token and attributed to an X-User header; the hosted site's Worker adds both for a signed-in Discord member, who taps a name on the map to rename it.
  • What is here. GET /at?x=&z= answers for a spot in walked ground: the biome and height there, and the places it lies in -- lake or bay, range, biome region, landmass -- each with how much of it has been walked, its elevation span, and the pieces and portals standing on it. Unwalked ground gets nothing. On the map a tap anywhere opens a place card from it, most particular place first with chips for the others; a tap on a name opens that place. Signed in, the name is editable in the card.
  • Which names: the Names chip in the Layers card switches every label, and hovering it (or its caret, on a phone) opens the kinds -- continents and islands, holms, lakes and bays, rivers, mountain ranges, and each biome -- as switches of their own.
  • The portals atlas for big hubs and several of them. A hub can be named on the page (a pencil beside its title, signed in; the name is kept by the server as a spot, hub@x,z, through POST /names, and a house pin still names a hub with no given name); the tags become a detail line. A big hub's panel shows the gates you can act on and folds the linked ones behind a count that opens on a tap; its card lists spokes by compass sector, nearest first, with a box to find a gate past ten. With more than one hub, chips at the top pick one: the view fits its reach and the rest fade. A tagged portal standing unlinked now draws one dashed line, to the only hub with a gate to spare, else the hub it last led to, else the nearest -- the mod remembers each portal's last twin (portals.tsv, last on the portal) across restarts.
  • Stats that use distance as the yardstick, never time. Per player: metres walked and deaths by the biome underfoot, so the players page shows danger by biome as "1 death per N km"; close calls (health under a tenth and back over three tenths) with the lowest survived; the longest walk between deaths and the walk since the last one; corpse runs as recovered (back at the spot), failed (died within 200 m of it first), rescued (the grave emptied by someone else) and still out there, with the longest run. The world's boss keys become a board of who has fallen, in order. /stats/players carries biomes, close_calls, lowest_hp, streak_m, runs, run_m and bosses; stats.tsv keeps them. Each card links to that player's deaths on the map (#details=deaths&who=<name>), to the spot of the last one, and to the graves layer; the place card says how many died at a place.
  • One view for every page. MapCore.view holds the window onto the map -- pan on a drag, wheel and pinch zoom, fits, one paint a frame, the middle kept when the sidebar folds, a tap told from a drag -- and the map, the atlas and the plan use it instead of three copies of the same code. The pages also share plural and clamp from the core.
  • Every colour a page names is a token now: the map's hues (portal, trader, grave), the atlas's ground, the halos and shadows over the world, the modal, the scrollbar, a live card's edge, a nil figure. site.css documents the system; a restyle is an edit to :root.
  • The bar offers a sign-in where the deployment's API has one (/auth/me); served by the mod alone it shows nothing.
  • Pins from the site. POST /pins with {"op":"add","x","z","type","text"} places a pin and answers its id; {"op":"edit","id"} with any of x, z, type, text changes one; {"op":"delete","id"} takes it up. Every answer carries the pins as they now stand. Guarded like /names: a new pin's owner is the X-User who placed it, any signed-in member may change any pin, and the server log says who did what. The text is at most 60 characters and a pin only ever lands on walked ground. A site pin has web where a game pin has its placer's platform id, and a w-prefixed id, so the chat commands never reach it. On the map a pin opens its card on a tap -- type, label, who placed it -- and signed in it can be relabelled, retyped, moved (the next tap is the new spot) or deleted (confirmed in the card); the ground card offers Pin here. The chat commands now find and remove under the pin list's lock, and pins.csv is written from a snapshot, since the site writes from HTTP threads.
  • Tests, where there were none. dotnet test WebMap.Tests runs the mod's logic from the built DLL on .NET 10 -- the geography and its names, hub names, the stats walk and its save, portal memory, pin writes from the site -- and node --test runs map-core.js's pure functions; CI runs both on every push and pull request. tools/check.mjs replaces shoot.mjs: every page in a headless Chrome over a live server, one line per check and a failing exit when one fails. TESTING.md ranks what is covered and what is not.

2.12.0

  • A Layers card on the map, as on Google Maps: a thumbnail of the other ground in the corner, and behind it the choice of ground -- the world render, or the flat biome atlas the portals page draws on, which carries no forest shading -- and presets of the legend (Default, Travel, Builds, Wilds, Bare). L cycles the ground. Graves are off by default, like portals.
  • One sidebar shell for the map, the portals and the plan: the ☰ in the bar folds it away, and the choice holds across pages. The portals page now draws its diagram full-height with the hub and pair lists beside it instead of a boxed chart above them.
  • Phones: the sidebar is a drawer, shut on arrival, opened by the ☰ or by the online pill on the map; the layers tray opens upward; the bar fades at its edge where it swipes.
  • Torches and fires: a Details row in the Layers card, off by default, draws torches, fire pits, hearths and braziers as warm points over the map -- one glow where a base is kept -- and a burnt-out one as a grey ring. The sweep now reads each fire's fuel and sends it as a fifth field on those pieces; a viewer on an older server treats every fire as lit.
  • Deaths and trails, two more details in the Layers card. Every death is kept with where it happened (deaths in /state, the last 500) and drawn as a red glow that fades over a month. Trails count, per map pixel, the times a player walked into it, kept in trails.bin beside the stats and rendered each sweep as /trails, a faint blue over the ground; rev.trails in /state says when it moved.
  • The bundled viewer's file cache keys on each file's timestamp, so a viewer file replaced on disk is served at once instead of after the next restart.
  • Panning no longer lags. The map and the plan rendered every raster and every one of the thousands of build footprints again on every pointer event; now the scene renders once per zoom into a window wider than the screen, a pan slides it, and a zoom stretches it until a fresh render lands -- at once where that is quick, after the zoom settles where it is not. The footprints themselves draw as one path per colour instead of a fill each, and the deaths re-render only when the list changes.
  • One style system in site.css: the tokens (surfaces, ink, accents, radii, shadows), the base, and the pieces every page shares -- cards, stats, buttons, chips, fields, the legend, the map's button column, section rules. The pages' own styles shrank to what each alone draws. The bar underlines the page you are on, controls ease between states, focus is visible, and scrollbars are thin everywhere.
  • Hildir is her camp and nothing else. The trader scan matched any location whose name contained "hildir", which caught her three quest sites -- the mountain cave, the swamp crypt and the plains fortress -- and drew her standing in each of them. The three camps are now matched by name (Vendor_BlackForest, Hildir_camp, BogWitch_Camp), and the scan logs which it found.
  • The Log reads as chat again. A player asked for the web map's chat back: it had never gone, but the panel had become a join-and-death ticker with a line of chat every few hours lost in it -- and every disconnect was written twice, once by the announcement and once by the notifier. The server now marks its own lines (ev on each message), keeps chat and events to their own depth so a busy evening cannot push the chat out, and writes a leave once. The panel has a chat / all switch, chat by default, remembered per browser; a viewer on an older server falls back to treating a line with no speaker as an event.
  • Four bugs found by reading the paths the last few features added to:
    • A chat message was dropped whole if the server could not resolve the sender's character -- the position was looked up before anything else, though only the pin commands use it. Chat is now read first and the lookup happens only for a pin.
    • A pin command from a sender with no id would have matched every pin in the list (StartsWith("")), so the per-player trim could delete other players' pins and !undoPin could remove someone else's. Pins now need a known owner.
    • An RPC addressed to everybody runs both branches of RPC_RoutedRPC, so the two observation points saw it twice; only one of them de-duplicated. Both do.
    • The Discord webhook posted from the join and disconnect handlers on the game thread: a slow Discord stalled a player's join for as long as it took to fail, and a bad URL threw inside the handshake. The post goes to a pool thread, and an unset webhook no longer logs a line per join.

2.11.0

  • The bundled viewer is now the same site that fronts our own server: the live map with builds drawn as shaded roofs and floor plans, the portal atlas on a biome chart, the planning board, and the players page — served by the mod on its own port, so it needs no proxy and no hosting. The old viewer, its webpack build and the websocket pings it showed are gone; the pages poll /state instead.
  • Pages are served with no-cache so a new build shows on the next visit; assets for five minutes. They were cached for a week.

2.10.0

  • render_size: the world render at its own resolution. 4096 halves the metres per pixel over the same area; the overlays stay at texture_size. The render is a coroutine that yields every row, so it no longer freezes the server, and a map.png of the wrong width is rebuilt rather than served.
  • Structure sweeps run only while someone is reading the map. A request to any layer or sweep-fed JSON arms them for two minutes and they start at least a minute apart; an idle server does no sweep work. The fixed two-minute timer and the immediate re-sweep on /structures/refresh are gone. Every sweep measures itself — ZDOs, game-thread milliseconds for walk and finish, frames yielded, wall time, gen-2 collections — as a sweep field on /structures/stats and one log line.
  • /pieces: every placed piece as [prefab, x, z, yaw] against a small table of prefab footprint and colour, for viewers that draw builds as vectors. Recorded in the same sweep and the same ZDO visit; about 60 KB for a world.
  • /portals: portals with their tag and the portal each is linked to, taken from the game's own connection rather than from matching names.
  • /graves: tombstones still holding gear, with owner and seconds since the death.
  • The chat observer compares the method hash before doing anything else. It used to hash, key and de-duplicate every routed RPC the server forwards, on the game thread, thousands a second with a few players on. Also removed: a postfix on GetStableHashCode that wrote two dictionaries on every string the game hashes, and the ZSyncAnimation hooks beside it.
  • /fog encodes its PNG once per change instead of on every request, and request paths drop their query string before routing, so a cache-busting parameter no longer 404s.
  • Player state is read by ZDOVars hash and /config is built on the game thread when the world loads, so HTTP threads no longer touch ZNet.
  • Everything a sweep does after the walk — rendering the overlays, the forest blur, the JSON, the PNG encodes — runs on a pool thread. The game thread pays for the walk and nothing else; the ~140 ms finish it used to absorb is gone. Overlays are encoded with EncodeArrayToPNG, which Unity marks thread-safe; EncodeToPNG on a Texture2D, which was being called from HTTP threads, is not.
  • The fog is kept as bytes and encoded the same thread-safe way; it was a Texture2D encoded on HTTP threads while the game thread painted it. /structures/refresh is gone: reading any layer arms a sweep, which is all it did.
  • /state: every small JSON block in one document per tick, with a content revision per layer. A viewer polls one URL and fetches a layer only when its revision moved; ?v= on a layer request lets a cache keep it as long as it likes. The bundled viewer polls it every 30 s instead of re-pulling both overlays on timers, and loads the world render as the JPEG: browsers gave up on the 16 MB PNG a 4096 render produces part way through, after which the viewer never got to its overlays, pins or messages.
  • /chart: the world as a flat chart, each pixel its biome's colour and water one blue, sampled from the world generator once per world and kept beside map.png.
  • Traders (Haldor, Hildir, the Bog Witch) in /state, in explored ground only: the world generated them, and a player's own map pins them once they have been near.
  • /stats/players: per-player tallies — joins, deaths, chat lines, distance covered, portal hops, pins, and what is standing in the world with their name on it (pieces, portals, ships, graves). No time played, by design. Persisted beside the world's map data.

2.9.1

  • Removed a Harmony prefix on ZRoutedRpc.InvokeRoutedRPC that ran for every outgoing routed RPC and did nothing unless debug or test was set. Chat is observed at RouteRPC instead, so it was redundant as well as costly.
  • Removed the test setting it existed for, which rerouted DiscoverLocationRespons to everybody, and discord_invite_url, which was read by nothing at all.
  • Removed MapDataServer.BroadcastMessage, which had no callers.

2.9.0

  • Forest and logging overlay (/forest, /forest/stats): trees shade the terrain, cleared ground shows through. The shading saturates smoothly rather than clamping, so thinning a wood is visible and not just clear-felling it; /forest/stats reports the density percentiles behind the picture.
  • Boats and carts (/vehicles): reported with position and type rather than painted into the structures layer as anonymous pieces. Detected by Ship/Vagon component, so later additions are picked up without a name list.
  • Server announcements (POST /announce) via MessageHud.ShowMessage, so they reach unmodded clients; chat cannot be used for this, see the README.
  • World render also served as JPEG (/map.jpg), roughly a seventh of the PNG.
  • Chat is observed at ZRoutedRpc.RouteRPC, where 1.0 actually routes it, instead of HandleRoutedRPC, which never sees a message addressed to another player.
  • Removed the fake server-client patches: they block joins on 1.0 and are unnecessary now.

Fixes that matter now that chat is actually observed:

  • The chat lists are no longer edited from three threads at once. They are written from the game thread, drained by a timer on a pool thread and read by HTTP on a third; /messages now serves a snapshot instead of walking a list another thread is editing.
  • Chat observation decides whether a routed RPC is chat before looking up the sender. It now runs for every RPC the server forwards, and used to throw and catch a NullReferenceException on each one whose sender was not a live peer.
  • No more one console line per chat message and per ping; both are behind debug.
  • The static file cache is concurrent: a browser opens several connections on first load and a plain Dictionary can corrupt under that.
  • Static files resolve through Path.GetFileName, so a backslash in the URL cannot walk out of the web root on Windows.
  • /messages sends application/json rather than applicaion/json.
  • Player state is built once on the game thread and read from there. The broadcast timer runs on a pool thread and HTTP on others again, and both used to read ZDOs and walk ZNet's live peer list themselves.
  • /vehicles only reports what is in explored territory, and show_vehicles turns it off entirely.
  • The bundled map viewer draws the structures and forest layers. The server has produced both since 2.8.0, but the viewer never asked for them, so the structures heatmap looked missing even with the sweep plainly running in the log. The menu has a toggle for each.
  • package.sh builds the web bundle. web/main.js is a build product and gitignored, so packaging from a fresh clone shipped a viewer with no script at all.

2.8.0

  • Build for Valheim 1.0 (Deep North).
  • Add a player-built structures overlay (/structures, /structures/stats, /structures/refresh), coloured by material albedo with piece density driving opacity.
  • Add death notices to the message feed.
  • Route image encode/decode through reflection so the mod builds against the 1.0 assemblies.
  • Disable the fake server-side chat client: on 1.0 it prevents players from joining. See "Known issues" in the README.

2.7.1 and earlier

See upstream.