Modding

Waypoint Map

M opens a map; click for waypoints (Shift+click: shared with everyone). Waypoints show on the compass.

Updated 11 Oct 2026

M opens a map; click for waypoints (Shift+click: shared with everyone). Waypoints show on the compass.

Mod id example.waypointmap, side both.

A code mod: build it with dotnet build (see Making mods) and put the output folder in the game's Mods folder.

mod.json

{
    "id": "example.waypointmap",
    "name": "Waypoint Map",
    "version": "1.1",
    "author": "Seeds & Circuits",
    "side": "both",
    "description": "M opens a map; click for waypoints (Shift+click: shared with everyone). Waypoints show on the compass."
}

WaypointMap.cs

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using SeedsAndCircuits.DebugConsole;
using SeedsAndCircuits.Inventory;
using SeedsAndCircuits.Mods;
using SeedsAndCircuits.Player;
using SeedsAndCircuits.Player.UI;
using SeedsAndCircuits.Sky;
using SeedsAndCircuits.Terrain;
using UnityEngine;
using Compass = SeedsAndCircuits.Player.UI.Compass;   // UnityEngine has a Compass too (the device sensor)
using UnityEngine.InputSystem;

namespace WaypointMap
{
    /// <summary>
    /// M opens a map of the whole world (drawn from the terrain's data, so all of it, loaded or not), centred on you. Click to set a waypoint, Shift+click
    /// for a shared one everyone in the game sees, right-click to remove one. Waypoints show on the compass too.
    /// <list type="bullet">
    /// <item>Private waypoints are this player's own: saved on their machine, per world.</item>
    /// <item>Shared waypoints live on the host: players ask the host to add or remove one, the host keeps them in the
    /// world save (<c>WorldData</c>) and sends the list to everyone (and to each player who joins).</item>
    /// <item>The key is rebindable (Settings ▸ Controls, "Waypoint Map: Open Map"); Settings ▸ Mods has its options.</item>
    /// </list>
    /// Side "both": everyone in a game needs it, because shared waypoints go through the host's copy.
    /// </summary>
    public sealed class WaypointMap : Mod
    {
        [Serializable]
        sealed class Waypoint
        {
            public string name;
            public Vector3 position;
            public bool shared;
            public string by;
        }

        [Serializable]
        sealed class WaypointList
        {
            public string world;
            public List<Waypoint> waypoints = new List<Waypoint>();
        }

        static readonly Color PrivateColor = new Color(0.45f, 0.75f, 1f, 1f);
        static readonly Color SharedColor = new Color(0.45f, 1f, 0.55f, 1f);
        const int MapPixels = 1024;
        const int RowsPerFrame = 128;
        const float MaxZoom = 12f;
        const float ClickRadius = 14f;

        static WaypointMap s_Instance;

        WaypointList _private = new WaypointList();
        WaypointList _shared = new WaypointList();     // host: the real list; players: the host's last copy
        string _worldKey;

        bool _open;
        // The whole world's map: drawn once per world, then zoomed and dragged.
        Texture2D _map;
        Color32[] _pixels;
        int _drawnRows;
        Rect _area;
        float _zoom = 4f;
        Vector2 _view = new Vector2(0.5f, 0.5f);
        Vector2 _dragStart;
        bool _dragged;
        GUIStyle _arrow;
        GUIStyle _label;

        InputAction _openKey;
        ModToggle _onCompass;
        ModSlider _mapSize;

        // ------------------------------------------------------------------ lifecycle

        public override void OnLoad()
        {
            s_Instance = this;
            _openKey = AddKey("Open Map", "<Keyboard>/m");
            _onCompass = AddToggle("compass", "Waypoints on the compass", true);
            _mapSize = AddSlider("size", "Map size (% of the screen)", 50f, 95f, 85f, 5f);
            // Host: players ask for changes to the shared list.
            OnMessage("add", msg =>
            {
                if (!IsServer)
                    return;
                Waypoint w = Json.FromJson<Waypoint>(msg.Text);
                if (w == null || string.IsNullOrWhiteSpace(w.name))
                    return;
                w.shared = true;
                w.name = w.name.Trim();
                _shared.waypoints.RemoveAll(x => x.name == w.name);
                _shared.waypoints.Add(w);
                SaveShared();
                SendShared();
            });
            OnMessage("remove", msg =>
            {
                if (!IsServer || _shared.waypoints.RemoveAll(x => x.name == msg.Text) == 0)
                    return;
                SaveShared();
                SendShared();
            });
            // Everyone (the host too): the host's shared list, and which world it is.
            OnMessage("shared", msg =>
            {
                WaypointList list = Json.FromJson<WaypointList>(msg.Text);
                if (list == null)
                    return;
                _shared = list;
                if (_worldKey != list.world)
                {
                    _worldKey = list.world;
                    LoadPrivate();
                }
            });
            Compass.CollectMarkers += AddCompassMarkers;
        }

        public override void OnUnload()
        {
            Compass.CollectMarkers -= AddCompassMarkers;
            Close();
            ResetMap();
            s_Instance = null;
        }

        public override void OnWorldLoaded()
        {
            if (!IsServer)
                return;     // players get the world and list from the host when they join
            // The host's world: its name plus the host's player id, so two hosts' "World 1"s don't mix.
            _worldKey = $"{WorldName}-{Short(InventoryStore.LocalPlayerId)}";
            _shared = WorldData.Get<WaypointList>("shared") ?? new WaypointList();
            _shared.world = _worldKey;
            LoadPrivate();
            SendShared();
        }

        public override void OnWorldUnloaded()
        {
            Close();
            ResetMap();
            _shared = new WaypointList();
            _private = new WaypointList();
            _worldKey = null;
        }

        public override void OnPlayerJoined(ulong clientId) =>
            SendToClient(clientId, "shared", Json.ToJson(_shared));

        // ------------------------------------------------------------------ files

        static string Short(string id) => string.IsNullOrEmpty(id) ? "host" : id.Length > 8 ? id.Substring(0, 8) : id;

        static string Safe(string name) => string.Concat((name ?? "world").Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));

        string PrivatePath => Path.Combine(DataFolder, "private", Safe(_worldKey) + ".json");

        WaypointList Load(string path)
        {
            try
            {
                return File.Exists(path) ? Json.FromJson<WaypointList>(File.ReadAllText(path)) : null;
            }
            catch (Exception e)
            {
                LogWarning($"Could not read {path}: {e.Message}");
                return null;
            }
        }

        void Save(string path, WaypointList list)
        {
            Directory.CreateDirectory(Path.GetDirectoryName(path));
            File.WriteAllText(path, Json.ToJson(list, pretty: true));
        }

        void LoadPrivate() => _private = (_worldKey != null ? Load(PrivatePath) : null) ?? new WaypointList { world = _worldKey };

        void SavePrivate()
        {
            if (_worldKey != null)
                Save(PrivatePath, _private);
        }

        // In the world save: it moves, copies and gets deleted with the world.
        void SaveShared() => WorldData.Set("shared", _shared);

        void SendShared() => SendToClients("shared", Json.ToJson(_shared));

        // ------------------------------------------------------------------ waypoints

        IEnumerable<Waypoint> All => _private.waypoints.Concat(_shared.waypoints);

        string NextName()
        {
            for (int i = 1; ; i++)
            {
                string name = $"Waypoint {i}";
                if (All.All(w => w.name != name))
                    return name;
            }
        }

        void Add(string name, Vector3 position, bool shared)
        {
            var w = new Waypoint { name = name, position = position, shared = shared, by = PlayerSpawn.Local != null ? PlayerSpawn.Local.DisplayName : null };
            if (shared)
            {
                SendToServer("add", Json.ToJson(w));     // the host adds it and sends everyone the new list
                return;
            }
            _private.waypoints.RemoveAll(x => x.name == name);
            _private.waypoints.Add(w);
            SavePrivate();
        }

        bool Remove(string name)
        {
            if (_private.waypoints.RemoveAll(x => x.name == name) > 0)
            {
                SavePrivate();
                return true;
            }
            if (_shared.waypoints.Any(x => x.name == name))
            {
                SendToServer("remove", name);
                return true;
            }
            return false;
        }

        void AddCompassMarkers(List<Compass.ExtraMarker> markers)
        {
            if (!_onCompass.Value)
                return;
            foreach (Waypoint w in All)
                markers.Add(new Compass.ExtraMarker(w.position, w.shared ? SharedColor : PrivateColor));
        }

        // ------------------------------------------------------------------ map

        public override void OnUpdate()
        {
            DrawMapSlice();
            if (PlayerSpawn.Local == null || DebugConsole.IsOpen)
                return;
            // Mod keys only work during play; with the map open the game's controls are off, so the same key is read
            // straight from the keyboard to close it.
            if (!_open && _openKey.WasPressedThisFrame())
                Open();
            else if (_open && PressedNow(_openKey))
                Close();
        }

        /// <summary>Escape closes the map (instead of opening the pause menu).</summary>
        public override void OnCloseScreen() => Close();

        static bool PressedNow(InputAction action)
        {
            foreach (InputBinding binding in action.bindings)
            {
                if (InputSystem.FindControl(binding.effectivePath) is UnityEngine.InputSystem.Controls.ButtonControl button && button.wasPressedThisFrame)
                    return true;
            }
            return false;
        }

        void Open()
        {
            _open = true;
            // Frees the cursor and turns the game's controls off (moving, tools, hotbar scroll...) until closed.
            ScreenOpen = true;
            // Centred on you.
            if (PlayerSpawn.Local != null && _area.width > 0f)
                _view = ToMap(PlayerSpawn.Local.Feet);
        }

        void Close()
        {
            if (!_open)
                return;
            _open = false;
            ScreenOpen = false;
        }

        /// <summary>Photographs the area around the player from straight above, north up.</summary>
        // ------------------------------------------------------------------ the map picture

        /// <summary>
        /// Draws the whole world into the map texture a few rows per frame after the world loads, from the terrain's
        /// data (TerrainWorld.RenderMap), so it's ready by the time the map opens and costs no frame much.
        /// </summary>
        void DrawMapSlice()
        {
            TerrainWorld world = TerrainWorld.Instance;
            if (world == null || !world.IsInitialized || PlayerSpawn.Local == null)
                return;
            if (_pixels == null)
            {
                _area = world.MapArea(PlayerSpawn.Local.Feet, 2048f);
                _pixels = new Color32[MapPixels * MapPixels];
                _drawnRows = 0;
            }
            if (_drawnRows >= MapPixels)
                return;
            if (!world.RenderMap(_area, MapPixels, _pixels, _drawnRows, RowsPerFrame))
                return;
            _drawnRows = Mathf.Min(MapPixels, _drawnRows + RowsPerFrame);
            if (_drawnRows < MapPixels && !_open)
                return;
            // Show progress while the map is open; upload once at the end otherwise.
            if (_map == null)
                _map = new Texture2D(MapPixels, MapPixels, TextureFormat.RGBA32, true) { name = "Waypoint Map", wrapMode = TextureWrapMode.Clamp };
            _map.SetPixels32(_pixels);
            _map.Apply();
        }

        void ResetMap()
        {
            _pixels = null;
            _drawnRows = 0;
            if (_map != null)
                UnityEngine.Object.Destroy(_map);
            _map = null;
        }

        /// <summary>World position → 0..1 across the whole map (x east, y north).</summary>
        Vector2 ToMap(Vector3 world) => new Vector2((world.x - _area.xMin) / _area.width, (world.z - _area.yMin) / _area.height);

        Vector3 ToWorld(Vector2 map)
        {
            float x = _area.xMin + map.x * _area.width;
            float z = _area.yMin + map.y * _area.height;
            Vector3 me = PlayerSpawn.Local != null ? PlayerSpawn.Local.Feet : Vector3.zero;
            // The ground under the click where it's loaded; elsewhere the player's height will do for a compass mark.
            return Physics.Raycast(new Vector3(x, me.y + 500f, z), Vector3.down, out RaycastHit hit, 1500f, ~0, QueryTriggerInteraction.Ignore)
                ? hit.point
                : new Vector3(x, me.y, z);
        }

        // ------------------------------------------------------------------ the map screen

        public override void OnGUI()
        {
            if (!_open)
                return;
            _label ??= new GUIStyle(GUI.skin.label) { fontSize = 14, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft };

            float size = Mathf.Min(Screen.width, Screen.height) * _mapSize.Value / 100f;
            var rect = new Rect((Screen.width - size) * 0.5f, (Screen.height - size) * 0.5f, size, size);
            GUI.color = new Color(0f, 0f, 0f, 0.6f);
            GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), Texture2D.whiteTexture);
            GUI.color = new Color(0.08f, 0.1f, 0.12f);
            GUI.DrawTexture(rect, Texture2D.whiteTexture);
            GUI.color = Color.white;

            // The visible part of the world map: 1 / zoom of it, around _view (0..1).
            float span = 1f / _zoom;
            _view.x = Mathf.Clamp(_view.x, span * 0.5f, 1f - span * 0.5f);
            _view.y = Mathf.Clamp(_view.y, span * 0.5f, 1f - span * 0.5f);
            var uv = new Rect(_view.x - span * 0.5f, _view.y - span * 0.5f, span, span);
            if (_map != null)
                GUI.DrawTextureWithTexCoords(rect, _map, uv);
            if (_drawnRows < MapPixels)
                GUI.Label(new Rect(rect.x + 10, rect.y + 10, 300, 24), $"Drawing the map... {100 * _drawnRows / MapPixels}%", _label);

            Vector2 ToScreen(Vector3 world)
            {
                Vector2 m = ToMap(world);
                return new Vector2(rect.x + (m.x - uv.x) / uv.width * rect.width, rect.y + (1f - (m.y - uv.y) / uv.height) * rect.height);
            }

            GUI.BeginGroup(rect);
            var local = new Rect(0, 0, rect.width, rect.height);
            Vector2 Local(Vector3 world) => ToScreen(world) - rect.position;
            foreach (PlayerSpawn p in PlayerSpawn.All)
            {
                if (p != null && p != PlayerSpawn.Local)
                    Dot(Local(p.Feet), new Color(0.55f, 0.85f, 1f), p.DisplayName);
            }
            foreach (Waypoint w in All)
                Dot(Local(w.position), w.shared ? SharedColor : PrivateColor, w.name + (w.shared && !string.IsNullOrEmpty(w.by) ? $" ({w.by})" : string.Empty));
            PlayerSpawn me = PlayerSpawn.Local;
            if (me != null)
                Arrow(Local(me.Feet), Camera.main != null ? Camera.main.transform.eulerAngles.y : me.transform.eulerAngles.y);
            GUI.EndGroup();

            GUI.color = Color.white;
            GUI.Label(new Rect(rect.x, rect.yMax + 4, rect.width, 24),
                $"Click: waypoint · Shift+click: shared · Right-click: remove · Drag: move · Scroll: zoom · {_openKey.GetBindingDisplayString()}: close", _label);

            Event e = Event.current;
            if (!rect.Contains(e.mousePosition) && e.type != EventType.MouseUp && e.type != EventType.MouseDrag)
                return;
            switch (e.type)
            {
                case EventType.ScrollWheel:
                {
                    // Zoom around the point under the mouse.
                    Vector2 m = new Vector2(uv.x + (e.mousePosition.x - rect.x) / rect.width * uv.width,
                                            uv.y + (1f - (e.mousePosition.y - rect.y) / rect.height) * uv.height);
                    float zoom = Mathf.Clamp(_zoom * (e.delta.y > 0 ? 0.8f : 1.25f), 1f, MaxZoom);
                    _view = m + (_view - m) * (_zoom / zoom);
                    _zoom = zoom;
                    e.Use();
                    break;
                }
                case EventType.MouseDown when e.button == 0:
                    _dragStart = e.mousePosition;
                    _dragged = false;
                    e.Use();
                    break;
                case EventType.MouseDrag when e.button == 0:
                    if ((e.mousePosition - _dragStart).sqrMagnitude > 16f)
                        _dragged = true;
                    _view -= new Vector2(e.delta.x / rect.width * uv.width, -e.delta.y / rect.height * uv.height);
                    e.Use();
                    break;
                case EventType.MouseUp when e.button == 0:
                    if (!_dragged && rect.Contains(e.mousePosition))
                    {
                        var m = new Vector2(uv.x + (e.mousePosition.x - rect.x) / rect.width * uv.width,
                                            uv.y + (1f - (e.mousePosition.y - rect.y) / rect.height) * uv.height);
                        Add(NextName(), ToWorld(m), shared: e.shift);
                    }
                    e.Use();
                    break;
                case EventType.MouseDown when e.button == 1:
                {
                    Waypoint near = All.OrderBy(w => (ToScreen(w.position) - e.mousePosition).sqrMagnitude).FirstOrDefault();
                    if (near != null && (ToScreen(near.position) - e.mousePosition).magnitude <= ClickRadius)
                        Remove(near.name);
                    e.Use();
                    break;
                }
            }
        }

        void Dot(Vector2 at, Color color, string label)
        {
            GUI.color = Color.black;
            GUI.DrawTexture(new Rect(at.x - 6, at.y - 6, 12, 12), Texture2D.whiteTexture);
            GUI.color = color;
            GUI.DrawTexture(new Rect(at.x - 4, at.y - 4, 8, 8), Texture2D.whiteTexture);
            if (string.IsNullOrEmpty(label))
                return;
            GUI.color = Color.black;
            GUI.Label(new Rect(at.x + 9, at.y - 11, 300, 22), label, _label);
            GUI.color = color;
            GUI.Label(new Rect(at.x + 8, at.y - 12, 300, 22), label, _label);
        }

        /// <summary>You: a white arrow pointing the way you look (north up).</summary>
        void Arrow(Vector2 at, float yaw)
        {
            Matrix4x4 before = GUI.matrix;
            GUIUtility.RotateAroundPivot(yaw, at);
            GUI.color = Color.black;
            GUI.Label(new Rect(at.x - 13, at.y - 15, 30, 30), "▲", _arrow ??= new GUIStyle(GUI.skin.label) { fontSize = 26, alignment = TextAnchor.MiddleCenter });
            GUI.color = Color.white;
            GUI.Label(new Rect(at.x - 15, at.y - 16, 30, 30), "▲", _arrow);
            GUI.matrix = before;
        }

        // ------------------------------------------------------------------ console

        [ConsoleCommandAttribute("waypoint.add", "A waypoint at your feet (add 'shared' for everyone).", Usage = "<name> [shared]", Local = true)]
        static string AddCommand(ConsoleArgs args)
        {
            WaypointMap mod = s_Instance;
            PlayerSpawn local = PlayerSpawn.Local;
            if (mod == null || local == null)
                return "Not in a world.";
            bool shared = args.Count > 1 && args[args.Count - 1] == "shared";
            string name = args.Count == 0 ? mod.NextName() : string.Join(" ", Enumerable.Range(0, shared ? args.Count - 1 : args.Count).Select(i => args[i]));
            mod.Add(name, local.Feet, shared);
            return $"Waypoint \"{name}\"{(shared ? " shared" : string.Empty)}.";
        }

        [ConsoleCommandAttribute("waypoint.remove", "Removes a waypoint.", Usage = "<name>", Local = true)]
        static string RemoveCommand(ConsoleArgs args)
        {
            WaypointMap mod = s_Instance;
            string name = args.Rest(0) ?? throw new ConsoleArgumentException("Remove which?");
            return mod != null && mod.Remove(name) ? $"Removed \"{name}\"." : $"No waypoint \"{name}\".";
        }

        [ConsoleCommandAttribute("waypoint.list", "Your waypoints and the shared ones.", Local = true)]
        static string ListCommand()
        {
            WaypointMap mod = s_Instance;
            if (mod == null || !mod.All.Any())
                return "No waypoints.";
            Vector3 me = PlayerSpawn.Local != null ? PlayerSpawn.Local.Feet : Vector3.zero;
            return string.Join("\n", mod.All.Select(w =>
                $"{(w.shared ? "[shared] " : string.Empty)}{w.name}: {Vector3.Distance(me, w.position):0} m{(w.shared && !string.IsNullOrEmpty(w.by) ? $" (by {w.by})" : string.Empty)}"));
        }
    }
}

WaypointMap.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <!-- Build settings and game references: ../Mod.props. Output: bin/Waypoint Map/ -->
  <PropertyGroup>
    <AssemblyName>WaypointMap</AssemblyName>
    <ModFolder>Waypoint Map</ModFolder>
  </PropertyGroup>
  <Import Project="../Mod.props" />
</Project>