Skip to main content
Whenever you save a .cs or .razor file, s&box recompiles your project and attempts to live-reload the changed assembly. You can iterate on most code changes without ever restarting the editor.

How it works

When a type definition changes, s&box walks the heap starting from static fields, recursing into instance fields to find and upgrade every live instance of the changed types.

IL hotload (fast hotload)

If you only change method bodies — not type definitions — s&box patches in the new instructions directly without walking the heap. This is nearly instant and avoids most of the pitfalls described below. IL hotload is enabled by default. You can disable it in Editor Settings > General.
If disabling fast hotload fixes a problem you’re seeing, let the s&box team know.

Optimizing hotload speed

Heap walking can be slow when there are many instances. These tips apply to full hotloads, not IL hotloads.

Diagnose slow hotloads

Enter hotload_log 2 in the console before saving a file. On the next hotload, s&box prints a table showing how long it spent on each type and how many instances it found, sorted by processing time. The biggest slowdown appears at the top. If instance counts grow after each hotload, you likely have a leak — for example, a static list you add to but never clear. The table also shows which static fields instances were discovered through.

Use value-type arrays

s&box has a fast path for arrays and lists whose element type is a value type with no reference fields:
public record struct UserStruct( Vector3 Foo, int Bar );

public int[]        IntArray;        // Fast
public Vector3[]    VectorArray;     // Fast
public UserStruct[] UserStructArray; // Fast

public string[]     StringArray;     // Slow
public object[]     ObjectArray;     // Slow

Skip processing with [SkipHotload]

s&box automatically skips types and fields that can’t possibly contain user-defined types. You can force a skip with [SkipHotload]:
[SkipHotload]
public static MyCache CachedData;
Use [SkipHotload] carefully. Skipping a field that does contain live instances will leak those instances into the post-hotload state and can cause hard-to-diagnose errors.
Sealing classes with sealed lets hotload confirm that no user type can inherit from them, allowing more types to be skipped automatically.

Pitfalls

These edge cases apply to full hotloads only, not IL hotloads.
If you remove or rename a type, all references to instances of that type become null. The engine removes components whose types disappear, but you may need to handle this in your own code too.Renamed types are treated as removed — s&box cannot detect that a rename happened. Restart the editor if you start seeing many errors after a rename.
Hotload copies the runtime value of fields into the new assembly. This means changes to default field values have no effect until you restart the editor.The exception is expression-bodied properties, const fields, and fields decorated with [SkipHotload]:
// Before hotload
public static string Example1 = "Hello";
public static string Example2 { get; } = "Hello";
public static string Example3 => "Hello";
public const  string Example4 = "Hello";
[SkipHotload] public static string Example5 = "Hello";

// After hotload — actual runtime value
public static string Example1 = "World";               // "Hello" — not updated
public static string Example2 { get; } = "World";      // "Hello" — not updated
public static string Example3 => "World";              // "World" — updated
public const  string Example4 = "World";               // "World" — updated
[SkipHotload] public static string Example5 = "World"; // "World" — updated
If you make a code change that causes two previously non-equal instances to compare as equal, dictionaries and sets keyed on those instances can enter an invalid state. s&box emits a warning when it detects this. You may need to restart the editor.
s&box cannot process static fields inside generic types during hotload. It emits a compile-time warning for any such fields. Their values are lost on hotload. Suppress the warning with [SkipHotload] if you are intentionally accepting that behavior.
Hotload attempts to preserve Delegate instances, but lambdas can fail to survive if methods that contain them are reordered or significantly restructured. When hotload cannot safely process a delegate, it replaces it with one that logs a warning when invoked.
If you cache results from reflection (e.g. Type.GetProperties()), those caches can become stale after a hotload because types change. Mark cached fields with [SkipHotload] and repopulate the cache after hotload.
Hotload suspends managed threads that could touch anything being processed. Worker thread tasks (GameTask.RunInThreadAsync, etc.) are allowed to finish their current yield point first. Write async tasks to yield often so hotload is not blocked for long.

Unit testing

If you add a UnitTests/ directory to your project folder, s&box automatically generates a unit test project for you. Restart the editor after creating the directory for the project to appear.

Writing your first test

using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public class MyFirstTest
{
    [TestMethod]
    public void Simple()
    {
        Assert.AreEqual( 4, 2 + 2 );
    }
}
Run tests with dotnet test from the CLI, or use the Test Explorer in Visual Studio.

Testing components

If your test needs engine functionality — for example to create a Scene and add components — initialize the engine in a shared setup file:
// InitTests.cs
global using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public class TestInit
{
    public static Sandbox.AppSystem TestAppSystem;

    [AssemblyInitialize]
    public static void AssemblyInitialize( TestContext context )
    {
        TestAppSystem = new TestAppSystem();
        TestAppSystem.Init();
    }

    [AssemblyCleanup]
    public static void AssemblyCleanup()
    {
        TestAppSystem.Shutdown();
    }
}
Then write component tests against a real scene:
using Sandbox;

[TestClass]
public class Camera
{
    [TestMethod]
    public void MainCamera()
    {
        var scene = new Scene();
        using var sceneScope = scene.Push();

        Assert.IsNull( scene.Camera );

        var go  = scene.CreateObject();
        var cam = go.Components.Create<CameraComponent>();

        Assert.IsNotNull( scene.Camera );

        go.Destroy();
        scene.ProcessDeletes();

        Assert.IsNull( scene.Camera );
    }
}