Please disclose if any significant portion of your mod was created using AI tools by adding the 'AI Generated' category. Failing to do so may result in the mod being removed from Thunderstore.
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
BepInEx pack for Valheim. Preconfigured with the correct entry point for mods and preferred defaults for the community.
Preferred version: 5.4.2350README
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 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_size4096 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
- 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
3000) 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 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 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. 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:
- In Discord, turn on Developer Mode (User Settings → Advanced), right-click your server and Copy Server ID.
- Stop the game server, put that id in
discord_guildunder[Server]inBepInEx/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 linediscord_guild<Tab><the id>inBepInEx/plugins/WebMap/map_data/settings.tsvinstead, 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 setpublic_url. - Admins can move sign-in.
discord_guildanddiscord_admin_rolecan 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_urlandauth_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_channelas 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 existingdiscord_webhook. - Chat, both ways. With
discord_chat_channelset andchat_relayon (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_guildand 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
contenton 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 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.
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, ofpiece,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,toolfor anything else held (a tankard),nonewith both hands empty or sheathed (fist weapons are the game's own unarmed). A sword and shield is a second of each.twohandedcounts on top of the family, for a two-handed melee weapon.armor-- the chest piece's weight, from its movement penalty:lightnone (leather, troll, lox, fenring, Askvin),mediumup to 3.5% (root),heavymore (bronze, iron, wolf, padded, carapace, flametal: 5%),magewhatever speeds eitr (eitr-weave),nonebare.
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.burntinstead 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.
- 1.0 update, structures, forest, vehicles, portals, graves and pieces by Hunter Boyd
- Maintained upstream by Jeff Clark
- Original work by Kyle Paulsen
- The 3D view's model export, asset readers, world objects, instanced viewer and sky are ported from f00d4tehg0dz/valheim-webmap (MIT); its ground and street-view camera are ours
- three.js r180 (MIT) is vendored under
WebMap/web/vendor/three - Background by webtreats, CC BY 2.0
CHANGELOG
Changelog
2.16.1
- The map page's script is split into files with one global each, as
map-core.jsandjs/card.jsare:js/tour.jsthe cinematic,js/street.jsthe 3D mode and the pegman,js/spot.jsthe spotlight (a turn the players and World pages can hand their own figures to) andjs/layers.jsthe Layers card, each handed what it reads by oneinit; 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 nowsite.css's. The helpers two pages defined aremap-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 -- orjs/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 markupmap-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 aremap-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.swwith 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 beforenode --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 asnameorstatusincluded), none declared and never read, novar, 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 threelets 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.mjsmeasured the builds' view throughPIECES, a name the state object retired: the probe threw in the page, and the bench printedundefinedfor the pieces and empty figures for its base view. It readsWORLD.piecesShownnow.
2.16.0
- Sign in with Discord on any server running the mod: set
discord_guildto 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 anddiscord_admin_rolechange 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/meand 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, orpublic_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/loginreturns 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_urlandauth_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, theDockerfileandentrypoint.sh,docker-bake.hcl,build/AssemblyPublicizerTool.cake, a VS Code task that ran Cake, and 5 MB of tool binaries that had been committed undertools/; the build publicises the game assemblies itself, andlibs/README.mdnow 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:
cibuilds, packages and tests every push and pull request, and the release workflow runs onv*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-Userit names,X-UserandX-Adminwithout 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,readyandclearance. The mod tells the locations at/locations, in walked ground only and built once a sweep, and/piecesrows carry a sixth field,by, the builder's index into a newplayerslist (-1for one never seen online), the fifth now-1on 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/playersgains akitchenper player (cooked,burnt,brewed,honey,smeltedanddishesby product) and one for the world with its busiest stations, kept instats.tsv(c,f,kandslines; 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.
/stateno longer carries the per-player tallies (a fifth of its bytes, rebuilt every request while anyone was on, read by no page:/stats/playershas 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/meonce, 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
/statecarries) and yaw instats.tsvas anlline, 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/playerscarries it aslookwithyaw; an older file reads as none. three.js loads with the first look shown. The rig builder moved out of the 3D view intojs/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.
/stateplayers carryyawandlook(the body, the colours, each slot's prefab and the parts to draw); the library gains a part per body and per worn item (categoryrig) 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'sUnityEngine.AnimationModule.dlltoo. -
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
/objectsas 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 asexport_ms_per_frameneeds, 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'sSoftReferenceableAssets.dlltoo. -
What each player holds and wears, for the site to tell an archer from a builder:
gearin/stats/players, kept instats.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, plustwohandedfor a two-handed melee weapon) and to the chest's weight class (light,medium,heavyby movement penalty,magefor eitr-weave), all sorted by the item's own data so new gear sorts itself;wornis the latest set by prefab name. Hits on creatures that pass through the server count asmelee,rangedormagic, withbackstabon 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 gainedsetSpinandsetOrbitDistfor 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,treesandrocksin/stats/players, kept instats.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.
biomesnow carries anOceanentry.
2.14.0
- Discord moves into the mod, and talks back.
discord_bot_token(withdiscord_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 todiscord_chat_channelas 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 existingdiscord_webhook.settings.htmloffers 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.htmllists the mod's settings with what each does and whether it applies live or at the next restart. The mod keeps what admins set insettings.tsvbeside the map data and lays it over the BepInEx config, which the host rewrites on restart.GET/POST /settingsbehind the shared token and anX-Adminthe 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
woff2now), 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.htmlreads 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 = falseskips 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,zlink. 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 underweb/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 /prefabsthe model library's index and/models/<file>a model or texture from it./objectsand/heightanswer 404 for a chunk nobody has walked, and nothing about one is even recorded./statecarriesrev.objects,rev.heightandrev.models, andtime(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
*_fracrocks, and MineRock) keeps a health per hit area in its ZDO;/objectsis nowOBJ2, carrying a mask of the areas gone per rock, and/prefabssays 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 Dor the arrows carry the circled spot over the land the way the camera faces,EorSpaceraise it,QorShiftalone lower it, andShiftwith 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.
/heighttakesstep(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á.
/featureslists them (kind, where the name sits, extent, a range's peak, a river's course),rev.featuresin/statesays 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 /nameswith{"id","name"}gives a place a name (an empty name returns the world's own), guarded by the announce token and attributed to anX-Userheader; 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, throughPOST /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,laston 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/playerscarriesbiomes,close_calls,lowest_hp,streak_m,runs,run_mandbosses;stats.tsvkeeps 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.viewholds 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 sharepluralandclampfrom 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.cssdocuments 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 /pinswith{"op":"add","x","z","type","text"}places a pin and answers its id;{"op":"edit","id"}with any ofx,z,type,textchanges 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 theX-Userwho 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 haswebwhere a game pin has its placer's platform id, and aw-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, andpins.csvis written from a snapshot, since the site writes from HTTP threads. - Tests, where there were none.
dotnet test WebMap.Testsruns 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 -- andnode --testrunsmap-core.js's pure functions; CI runs both on every push and pull request.tools/check.mjsreplacesshoot.mjs: every page in a headless Chrome over a live server, one line per check and a failing exit when one fails.TESTING.mdranks 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).
Lcycles 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 (
deathsin/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 intrails.binbeside the stats and rendered each sweep as/trails, a faint blue over the ground;rev.trailsin/statesays 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 (
evon 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!undoPincould 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
/stateinstead. - Pages are served with
no-cacheso 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 attexture_size. The render is a coroutine that yields every row, so it no longer freezes the server, and amap.pngof 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/refreshare gone. Every sweep measures itself — ZDOs, game-thread milliseconds for walk and finish, frames yielded, wall time, gen-2 collections — as asweepfield on/structures/statsand 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
GetStableHashCodethat wrote two dictionaries on every string the game hashes, and theZSyncAnimationhooks beside it. /fogencodes 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
/configis built on the game thread when the world loads, so HTTP threads no longer touchZNet. - 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;EncodeToPNGon aTexture2D, 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
Texture2Dencoded on HTTP threads while the game thread painted it./structures/refreshis 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 besidemap.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.InvokeRoutedRPCthat ran for every outgoing routed RPC and did nothing unlessdebugortestwas set. Chat is observed atRouteRPCinstead, so it was redundant as well as costly. - Removed the
testsetting it existed for, which reroutedDiscoverLocationResponsto everybody, anddiscord_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/statsreports 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 byShip/Vagoncomponent, so later additions are picked up without a name list. - Server announcements (
POST /announce) viaMessageHud.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 ofHandleRoutedRPC, 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;
/messagesnow 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. /messagessendsapplication/jsonrather thanapplicaion/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.
/vehiclesonly reports what is in explored territory, andshow_vehiclesturns 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.shbuilds the web bundle.web/main.jsis 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.