terminalCode Example

UndoSystem: Per-player undo functionality

calendar_today May 12, 2026 schedule ~1 min read person PatrickJr verified 50

UndoSystem: Per-Player Undo Functionality

This example shows the UndoSystem which provides per-player undo stacks for tracking spawned objects and allowing players to undo their actions.

CSHARP
using Sandbox.UI;
using System.Collections.Generic;
using System.Linq;

public class UndoSystem : GameObjectSystem<UndoSystem>
{
    Dictionary<long, PlayerStack> stacks = new();

    public UndoSystem(Scene scene) : base(scene) { }

    /// <summary>
    /// Get the undo stack for a specific SteamId
    /// </summary>
    public PlayerStack For(long steamId)
    {
        if (!stacks.TryGetValue(steamId, out var stack))
        {
            stack = new PlayerStack(steamId);
            stacks[steamId] = stack;
        }
        return stack;
    }

    /// <summary>
    /// Call this when a player disconnects to prevent memory leaks!
    /// </summary>
    public void RemovePlayer(long steamId)
    {
        stacks.Remove(steamId);
    }

    /// <summary>
    /// Remove a GameObject from all player undo stacks.
    /// </summary>
    public void Remove(GameObject go)
    {
        foreach (var stack in stacks.Values)
        {
            stack.Remove(go);
        }
    }

    /// <summary>
    /// Per-player undo stack
    /// </summary>
    public class PlayerStack
    {
        long steamId;
        List<Entry> entries = new();
        const int MaxUndoSteps = 128; // Bounded history prevents indefinite memory leaks

        public PlayerStack(long steamId)
        {
            this.steamId = steamId;
        }

        /// <summary>
        /// Create a new undo entry
        /// </summary>
        public Entry Create()
        {
            var entry = new Entry(steamId);
            entries.Add(entry);

            if (entries.Count > MaxUndoSteps)
            {
                entries.RemoveAt(0);
            }

            return entry;
        }

        /// <summary>
        /// Run the undo
        /// </summary>
        public void Undo()
        {
            while (entries.Count > 0)
            {
                var entry = entries[^1];
                entries.RemoveAt(entries.Count - 1);

                if (entry.Run())
                    return;
            }
        }

        /// <summary>
        /// Remove a GameObject from all entries in this stack.
        /// </summary>
        public void Remove(GameObject go)
        {
            foreach (var entry in entries)
                entry.Remove(go);
        }
    }

    /// <summary>
    /// An undo entry
    /// </summary>
    public class Entry
    {
        /// <summary>
        /// The name of the undo, should fit the format "Undo something". Like "Undo Spawn Prop".
        /// </summary>
        public string Name { get; set; }
        public string Icon { get; set; }

        long SteamId;
        HashSet<GameObject> gameObjects = new();

        internal Entry(long steamId)
        {
            SteamId = steamId;
        }

        /// <summary>
        /// Add a GameObject that should be destroyed when the undo is undone
        /// </summary>
        public void Add(GameObject go)
        {
            gameObjects.Add(go);
        }

        /// <summary>
        /// Add a collection of GameObjects that should be destroyed when the undo is undone
        /// </summary>
        public void Add(params IEnumerable<GameObject> gos)
        {
            foreach (var go in gos)
            {
                Add(go);
            }
        }

        /// <summary>
        /// Remove a GameObject from this entry so it will no longer be destroyed on undo.
        /// </summary>
        public void Remove(GameObject go)
        {
            gameObjects.Remove(go);
        }

        /// <summary>
        /// Run this undo
        /// </summary>
        public bool Run(bool sendNotice = true)
        {
            var actioned = false;

            foreach (var go in gameObjects)
            {
                if (go.IsValid())
                {
                    go.Destroy();
                    actioned = true;
                }
            }

            if (!actioned)
                return false;

            if (sendNotice)
            {
                var c = Connection.All.FirstOrDefault(x => x.SteamId == SteamId);
                if (c is not null)
                {
                    using (Rpc.FilterInclude(c))
                    {
                        UndoNotice(Name);
                    }
                }
            }

            return true;
        }

        [Rpc.Broadcast]
        public static void UndoNotice(string title)
        {
            Notices.AddNotice("cached", "#3273eb", $"Undo {title}".Trim(), 5);
            Sound.Play("sounds/ui/ui.undo.sound");
        }
    }
}

Key Features

  • Per-Player Stacks: Each player has their own undo stack tracked by SteamId
  • Bounded History: MaxUndoSteps (128) prevents memory leaks
  • GameObject Tracking: Entry.Add() tracks objects to destroy on undo
  • Rpc.FilterInclude: Sends undo notice only to the player who triggered it
  • Cleanup: RemovePlayer() must be called on disconnect to prevent leaks
  • Global Remove: Remove(GameObject) removes object from all stacks
  • Undo Notice: Shows UI notice and plays sound on undo

Usage Example

CSHARP
// When spawning a prop
var undo = Game.ActiveScene.Get<UndoSystem>()?.For(player.SteamId)?.Create();
undo.Name = "Spawn Prop";
undo.Add(spawnedProp);

// When player presses undo key
var stack = Game.ActiveScene.Get<UndoSystem>()?.For(player.SteamId);
stack?.Undo();

// When player disconnects
Game.ActiveScene.Get<UndoSystem>()?.RemovePlayer(player.SteamId);

Notes

  • Must call RemovePlayer() on disconnect to prevent memory leaks
  • Undo entries destroy GameObjects - they don't restore state
  • Uses Rpc.FilterInclude to target notices to specific players
  • Stack skips invalid entries (already destroyed objects)
Was this helpful?