diff --git a/examples/fabric-server/scopenet.properties b/examples/fabric-server/scopenet.properties
index 40e9fc5..5ba77dd 100644
--- a/examples/fabric-server/scopenet.properties
+++ b/examples/fabric-server/scopenet.properties
@@ -52,3 +52,10 @@ placeholders.enabled=true
# --- Client mod link (1.20.1) ------------------------------------------------
# Send balance, level, guild and claim info to players who have the SCOPENET client mod.
clientlink.enabled=true
+
+# --- BlueMap (1.20.1) --------------------------------------------------------
+# With the BlueMap mod installed, adds guild claims, spawn, warps, guild homes, markets and shops to the 3D map, and a
+# player panel when you click someone. Set the map's web address in the panel (Servers -> this server -> Map address).
+bluemap.enabled=true
+# Show every player's homes as pins. Off by default: homes are private and the map is public to anyone with the link.
+bluemap.show_homes=false
diff --git a/examples/paper/config.yml b/examples/paper/config.yml
index d22b626..bcf6ab3 100644
--- a/examples/paper/config.yml
+++ b/examples/paper/config.yml
@@ -114,6 +114,17 @@ integrations:
enabled: true
report-interval-seconds: 30
+# ==============================================================================
+# BlueMap
+# With BlueMap installed, SCOPENET adds guild claims (coloured, with the guild's icon and name), spawn, warps, guild
+# homes, markets and shops to the 3D map, and a player panel when you click someone. Put the map's web address in the
+# panel (Servers -> this server -> Map address) and players get a Live Map button in the launcher.
+# ==============================================================================
+bluemap:
+ enabled: true
+ # Show every player's homes as pins. Off by default: homes are private, and the map is public to anyone with the link.
+ show-homes: false
+
# ==============================================================================
# Client mod link
# Sends balance, level, guild and claim info to players who have the optional SCOPENET
diff --git a/integrations/bluemap/build.gradle b/integrations/bluemap/build.gradle
new file mode 100644
index 0000000..6f68fc0
--- /dev/null
+++ b/integrations/bluemap/build.gradle
@@ -0,0 +1,11 @@
+// SCOPENET's BlueMap addon: guild claims, spawn, warps, shops and a player side panel on the 3D map.
+// BlueMap's API is the same on Paper, Fabric and Forge, so this one module serves them all. It is only ever called
+// when BlueMap is installed, and BlueMap provides these classes at runtime (compileOnly).
+repositories {
+ maven { url = 'https://repo.bluecolored.de/releases' }
+}
+dependencies {
+ implementation project(':common')
+ compileOnly 'de.bluecolored.bluemap:BlueMapAPI:2.7.4'
+ implementation 'com.google.code.gson:gson:2.10.1'
+}
diff --git a/integrations/bluemap/src/main/java/net/scopenet/bluemap/MapAddon.java b/integrations/bluemap/src/main/java/net/scopenet/bluemap/MapAddon.java
new file mode 100644
index 0000000..aa8b107
--- /dev/null
+++ b/integrations/bluemap/src/main/java/net/scopenet/bluemap/MapAddon.java
@@ -0,0 +1,211 @@
+package net.scopenet.bluemap;
+
+import com.flowpowered.math.vector.Vector2d;
+import com.flowpowered.math.vector.Vector3d;
+import com.google.gson.JsonObject;
+import de.bluecolored.bluemap.api.BlueMapAPI;
+import de.bluecolored.bluemap.api.BlueMapMap;
+import de.bluecolored.bluemap.api.markers.Marker;
+import de.bluecolored.bluemap.api.markers.MarkerSet;
+import de.bluecolored.bluemap.api.markers.POIMarker;
+import de.bluecolored.bluemap.api.markers.ShapeMarker;
+import de.bluecolored.bluemap.api.math.Color;
+import de.bluecolored.bluemap.api.math.Shape;
+import net.scopenet.core.map.ChunkRegions;
+import net.scopenet.core.map.MapData;
+import net.scopenet.core.map.MapModel;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.*;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+import java.util.logging.Logger;
+
+/**
+ * Puts SCOPENET on the BlueMap 3D map:
+ *
+ *
guild land as coloured regions, with the guild's icon and name,
+ *
spawn, warps, guild homes (and homes, if the admin allows) as pins,
+ *
markets and shops as pins that list what is for sale,
+ *
a script that opens a side panel when you click a player.
+ *
+ * Start it once from the plugin or mod. It waits for BlueMap to come up, so the load order doesn't matter, and does
+ * nothing if BlueMap isn't installed.
+ */
+public final class MapAddon {
+ /** Where the addon gets its data. Both calls must be safe from any thread. */
+ public interface Feed {
+ MapData snapshot();
+ JsonObject playersJson();
+ }
+
+ static final String WEB_DIR = "assets/scopenet";
+ static final List ASSETS = List.of("scopenet.js", "scopenet.css", "spawn.svg", "warp.svg", "home.svg", "guild-home.svg", "market.svg", "shop.svg", "guild.svg");
+ static final String CLAIMS = "scopenet-claims", GUILDS = "scopenet-guilds", PLACES = "scopenet-places", SHOPS = "scopenet-shops";
+ static final float CLAIM_Y = 66f;
+
+ private final Feed feed;
+ private final Logger log;
+ private ScheduledExecutorService worker;
+ private volatile boolean enabled;
+
+ private MapAddon(Feed feed, Logger log) { this.feed = feed; this.log = log; }
+
+ /** Hook into BlueMap. Returns a handle to stop it. */
+ public static MapAddon start(Feed feed, Logger log) {
+ MapAddon addon = new MapAddon(feed, log);
+ BlueMapAPI.onEnable(addon::enable);
+ BlueMapAPI.onDisable(api -> addon.disable());
+ return addon;
+ }
+
+ public void stop() { disable(); }
+
+ // ---- lifecycle -------------------------------------------------------------------------
+
+ private synchronized void enable(BlueMapAPI api) {
+ try {
+ Path root = api.getWebApp().getWebRoot().resolve(WEB_DIR);
+ Files.createDirectories(root);
+ for (String name : ASSETS) copy(name, root.resolve(name));
+ api.getWebApp().registerScript(WEB_DIR + "/scopenet.js");
+ api.getWebApp().registerStyle(WEB_DIR + "/scopenet.css");
+ } catch (IOException | RuntimeException e) {
+ log.warning("SCOPENET could not add its files to BlueMap: " + e);
+ return;
+ }
+ enabled = true;
+ worker = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "scopenet-bluemap");
+ t.setDaemon(true);
+ return t;
+ });
+ worker.scheduleWithFixedDelay(() -> refresh(api), 2, 10, TimeUnit.SECONDS);
+ log.info("SCOPENET is adding guild claims, pins and the player panel to BlueMap.");
+ }
+
+ private synchronized void disable() {
+ enabled = false;
+ if (worker != null) worker.shutdownNow();
+ worker = null;
+ }
+
+ private static void copy(String name, Path target) throws IOException {
+ try (InputStream in = MapAddon.class.getResourceAsStream("/" + WEB_DIR + "/" + name)) {
+ if (in == null) throw new IOException("missing resource " + name);
+ Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ // ---- refresh ---------------------------------------------------------------------------
+
+ private void refresh(BlueMapAPI api) {
+ if (!enabled) return;
+ try {
+ MapData data = feed.snapshot();
+ writePlayers(api);
+ for (BlueMapMap map : api.getMaps()) update(map, data);
+ } catch (RuntimeException | IOException e) {
+ log.fine("SCOPENET map refresh failed: " + e);
+ }
+ }
+
+ private void writePlayers(BlueMapAPI api) throws IOException {
+ Path file = api.getWebApp().getWebRoot().resolve(WEB_DIR).resolve("players.json");
+ Path tmp = file.resolveSibling("players.json.tmp");
+ Files.writeString(tmp, feed.playersJson().toString(), StandardCharsets.UTF_8);
+ Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ private void update(BlueMapMap map, MapData data) {
+ String dimension = MapModel.dimensionOf(map.getId(), map.getName());
+ set(map, CLAIMS, "Guild claims", claimMarkers(data, dimension));
+ set(map, GUILDS, "Guild names", guildMarkers(data, dimension));
+ set(map, PLACES, "Spawn, warps & homes", pinMarkers(data, dimension, false));
+ set(map, SHOPS, "Markets & shops", pinMarkers(data, dimension, true));
+ }
+
+ /** Replace a marker set's contents in place, so the map doesn't flicker. */
+ private static void set(BlueMapMap map, String id, String label, Map wanted) {
+ MarkerSet set = map.getMarkerSets().computeIfAbsent(id, k -> MarkerSet.builder().label(label).toggleable(true).defaultHidden(false).build());
+ set.getMarkers().keySet().removeIf(key -> !wanted.containsKey(key));
+ wanted.forEach(set::put);
+ }
+
+ // ---- markers ---------------------------------------------------------------------------
+
+ private static Shape shape(ChunkRegions.Ring ring) {
+ List points = new ArrayList<>();
+ for (ChunkRegions.Point p : ring.points()) points.add(new Vector2d(p.x(), p.z()));
+ return new Shape(points);
+ }
+
+ private static Map claimMarkers(MapData data, String dimension) {
+ Map out = new LinkedHashMap<>();
+ for (MapData.ClaimRegion c : data.claims()) {
+ if (!c.dimension().equals(dimension)) continue;
+ Shape[] holes = c.holes().stream().map(MapAddon::shape).toArray(Shape[]::new);
+ ShapeMarker marker = ShapeMarker.builder()
+ .label(c.name())
+ .shape(shape(c.outer()), CLAIM_Y)
+ .holes(holes)
+ .lineColor(new Color(c.color(), 0.95f))
+ .fillColor(new Color(c.color(), 0.28f))
+ .lineWidth(3)
+ .depthTestEnabled(false)
+ .detail(c.detail())
+ .build();
+ out.put("claim-" + c.id(), marker);
+ }
+ return out;
+ }
+
+ /** One icon and name per area, in the middle of it, fading out when you are far away. */
+ private static Map guildMarkers(MapData data, String dimension) {
+ Map out = new LinkedHashMap<>();
+ for (MapData.ClaimRegion c : data.claims()) {
+ if (!c.dimension().equals(dimension)) continue;
+ POIMarker.Builder builder = POIMarker.builder()
+ .label(c.name() + " [" + c.tag() + "]")
+ .position(c.labelX(), CLAIM_Y + 1, c.labelZ())
+ .styleClasses("scopenet-guild-icon")
+ .maxDistance(1600)
+ .detail(c.detail());
+ builder = c.icon().isEmpty() ? builder.icon(WEB_DIR + "/guild.svg", 16, 16) : builder.icon(c.icon(), 16, 16);
+ out.put("guild-" + c.id(), builder.build());
+ }
+ return out;
+ }
+
+ private static Map pinMarkers(MapData data, String dimension, boolean shops) {
+ Map out = new LinkedHashMap<>();
+ for (MapData.Pin p : data.pins()) {
+ boolean isShop = p.kind().equals("market") || p.kind().equals("shop");
+ if (!p.dimension().equals(dimension) || isShop != shops) continue;
+ String icon = switch (p.kind()) {
+ case "spawn" -> "spawn.svg";
+ case "warp" -> "warp.svg";
+ case "home" -> "home.svg";
+ case "guild_home" -> "guild-home.svg";
+ case "market" -> "market.svg";
+ default -> "shop.svg";
+ };
+ out.put(p.id(), POIMarker.builder()
+ .label(p.label())
+ .position(new Vector3d(p.x(), p.y() + 1, p.z()))
+ .icon(WEB_DIR + "/" + icon, 16, 16)
+ .styleClasses("scopenet-pin", "scopenet-" + p.kind().replace('_', '-'))
+ .maxDistance(2400)
+ .detail(p.detail())
+ .build());
+ }
+ return out;
+ }
+}
diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/guild-home.svg b/integrations/bluemap/src/main/resources/assets/scopenet/guild-home.svg
new file mode 100644
index 0000000..bb2f090
--- /dev/null
+++ b/integrations/bluemap/src/main/resources/assets/scopenet/guild-home.svg
@@ -0,0 +1 @@
+
diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/guild.svg b/integrations/bluemap/src/main/resources/assets/scopenet/guild.svg
new file mode 100644
index 0000000..2028d40
--- /dev/null
+++ b/integrations/bluemap/src/main/resources/assets/scopenet/guild.svg
@@ -0,0 +1 @@
+
diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/home.svg b/integrations/bluemap/src/main/resources/assets/scopenet/home.svg
new file mode 100644
index 0000000..cdb1214
--- /dev/null
+++ b/integrations/bluemap/src/main/resources/assets/scopenet/home.svg
@@ -0,0 +1 @@
+
diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/market.svg b/integrations/bluemap/src/main/resources/assets/scopenet/market.svg
new file mode 100644
index 0000000..5ad9750
--- /dev/null
+++ b/integrations/bluemap/src/main/resources/assets/scopenet/market.svg
@@ -0,0 +1 @@
+
diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.css b/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.css
new file mode 100644
index 0000000..ee98a19
--- /dev/null
+++ b/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.css
@@ -0,0 +1,64 @@
+/* SCOPENET on BlueMap: marker styling and the player side panel. Loaded by BlueMap through the addon. */
+
+/* Guild icons sit on the map as small rounded badges, whatever size the uploaded image is. */
+.scopenet-guild-icon img,
+.bm-marker-poi.scopenet-guild-icon img {
+ width: 34px !important;
+ height: 34px !important;
+ object-fit: cover;
+ border-radius: 9px;
+ border: 2px solid rgba(255, 255, 255, 0.9);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.55);
+ background: #1a1d25;
+}
+.scopenet-pin img,
+.bm-marker-poi.scopenet-pin img {
+ width: 26px !important;
+ height: 26px !important;
+ filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.55));
+}
+
+/* Popup text inside marker details. */
+.scopenet-detail { font-size: 13px; line-height: 1.45; min-width: 160px; }
+.scopenet-detail span { opacity: 0.7; }
+.scopenet-detail code { background: rgba(255, 255, 255, 0.08); padding: 1px 5px; border-radius: 4px; }
+.scopenet-list { margin: 4px 0 0; padding-left: 16px; }
+
+/* Player panel (shown in a plain browser; inside the SCOPENET launcher the launcher shows its own). */
+#scopenet-panel {
+ position: fixed;
+ top: 64px;
+ right: 14px;
+ width: 300px;
+ max-height: calc(100vh - 90px);
+ overflow-y: auto;
+ z-index: 50;
+ padding: 16px;
+ border-radius: 14px;
+ background: rgba(20, 22, 30, 0.96);
+ color: #e6e8ee;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ box-shadow: 0 16px 48px -12px rgba(0, 0, 0, 0.75);
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
+ font-size: 13px;
+ animation: scopenet-in 0.18s ease;
+}
+@keyframes scopenet-in { from { opacity: 0; transform: translateX(18px); } }
+#scopenet-panel button.close {
+ position: absolute; top: 8px; right: 8px; width: 26px; height: 26px; border: 0; border-radius: 8px;
+ background: transparent; color: #9aa0ae; font-size: 18px; cursor: pointer;
+}
+#scopenet-panel button.close:hover { background: rgba(255, 255, 255, 0.08); color: #fff; }
+#scopenet-panel .head { display: flex; gap: 12px; align-items: center; padding-right: 26px; }
+#scopenet-panel .head img { width: 56px; height: 56px; border-radius: 16px; border: 3px solid #8b6cff; background: #1a1d25; image-rendering: pixelated; }
+#scopenet-panel h3 { margin: 0 0 2px; font-size: 17px; }
+#scopenet-panel .title { color: #fbbf24; font-size: 12px; }
+#scopenet-panel .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; }
+#scopenet-panel .chip { padding: 2px 9px; border-radius: 99px; background: rgba(255, 255, 255, 0.08); font-size: 11px; font-weight: 650; }
+#scopenet-panel .chip.lv { color: #8b6cff; }
+#scopenet-panel .chip.guild { color: #22d3ee; }
+#scopenet-panel .chip.rank { color: #fbbf24; }
+#scopenet-panel .facts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+#scopenet-panel .facts div { padding: 8px 10px; border-radius: 10px; background: rgba(255, 255, 255, 0.05); color: #9aa0ae; font-size: 11px; }
+#scopenet-panel .facts strong { display: block; color: #e6e8ee; font-size: 14px; margin-top: 2px; }
+#scopenet-panel .note { margin: 14px 0 0; padding: 10px 12px; border-radius: 10px; background: rgba(139, 108, 255, 0.12); color: #c9bfff; font-size: 12px; line-height: 1.4; }
diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.js b/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.js
new file mode 100644
index 0000000..3f93b23
--- /dev/null
+++ b/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.js
@@ -0,0 +1,115 @@
+/*
+ * SCOPENET on BlueMap. Registered by the SCOPENET addon, so it runs inside BlueMap's own page: the same in a browser and
+ * inside the SCOPENET launcher.
+ *
+ * - Click a player on the map to see who they are (level, guild, rank, title...), read from players.json that the
+ * server keeps up to date next to this file.
+ * - Inside the launcher (this page is in an iframe) the click is handed to the launcher, which shows the full side panel
+ * with the actions that need a signed-in account: teleport request, message, guild invite.
+ */
+(function () {
+ 'use strict';
+ var BASE = 'assets/scopenet/';
+ var PLAYER_PREFIX = 'bm-marker-bm-player-';
+ var embedded = window.parent && window.parent !== window;
+ var players = {};
+ var selected = null;
+
+ function esc(text) {
+ return String(text == null ? '' : text).replace(/[&<>"']/g, function (c) {
+ return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
+ });
+ }
+
+ /** The uuid behind a player marker element, or null. BlueMap ids them "bm-marker-bm-player-". */
+ function uuidFromId(id) {
+ return typeof id === 'string' && id.indexOf(PLAYER_PREFIX) === 0 ? id.substring(PLAYER_PREFIX.length) : null;
+ }
+
+ /** Which player marker is under this screen point? Works even where the marker ignores pointer events. */
+ function playerAt(x, y) {
+ var nodes = document.querySelectorAll('[id^="' + PLAYER_PREFIX + '"]');
+ for (var i = 0; i < nodes.length; i++) {
+ var r = nodes[i].getBoundingClientRect();
+ if (r.width === 0 && r.height === 0) continue;
+ if (x >= r.left - 6 && x <= r.right + 6 && y >= r.top - 6 && y <= r.bottom + 6) return uuidFromId(nodes[i].id);
+ }
+ return null;
+ }
+
+ function hours(seconds) {
+ seconds = Number(seconds) || 0;
+ return seconds >= 3600 ? Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm' : Math.floor(seconds / 60) + 'm';
+ }
+
+ function panel() {
+ var el = document.getElementById('scopenet-panel');
+ if (!el) { el = document.createElement('div'); el.id = 'scopenet-panel'; document.body.appendChild(el); }
+ return el;
+ }
+
+ function render() {
+ var el = document.getElementById('scopenet-panel');
+ if (!selected) { if (el) el.remove(); return; }
+ var p = players[selected] || players[selected.toLowerCase()] || {};
+ el = panel();
+ var name = p.name || 'Player';
+ el.innerHTML =
+ '' +
+ '