---
title: "Write a custom replay viewer module in C#"
url: https://recursiveai.org/onescope/cookbook/write-a-onescope-module/
description: "Implement the OneScope SDK interfaces for a custom deterministic simulation, load the module assembly, and inspect a runnable counter replay."
version: "1.0.0"
---

# Write a custom replay viewer module in C#

**OneScope** is a free, open-source viewer for deterministic libraries from Recursive AI. MIT or Apache-2.0.

[NuGet](https://www.nuget.org/packages/RecursiveAI.OneScope.Sdk) · [GitHub](https://github.com/recursive-ai-oss/onescope-csharp) · [Docs](https://recursiveai.org/onescope/docs/) · [Download example (.zip)](https://recursiveai.org/onescope/cookbook/write-a-onescope-module/write-a-onescope-module.zip)

A module teaches OneScope your replay format. This runnable counter example implements file loading, seeking, properties, and drawing.

Unzip [the example](https://recursiveai.org/onescope/cookbook/write-a-onescope-module/write-a-onescope-module.zip), then `dotnet run`.

For a new project:

```shell
dotnet add package RecursiveAI.OneScope.Sdk
```

Requires [.NET 10](https://dotnet.microsoft.com/download/dotnet/10.0) (net10.0); .NET 9 and earlier aren’t supported.

The example writes `counter.replay` and prints its state after seeking and rewinding.

## Implement the module

The input is eight bytes: ASCII `CNTR`, followed by the little-endian 32-bit step count `10`. The full program creates it directly, so no external files are needed. The project pins the SDK to 1.0.0.

```csharp
using System.Buffers;
using System.Buffers.Binary;
using System.Numerics;
using OneScope.Sdk;

// CNTR + a little-endian int32 step count; each step increments the counter.
byte[] bytes = [67, 78, 84, 82, 10, 0, 0, 0];
File.WriteAllBytes("counter.replay", bytes);
var source = new CounterModule().Open(bytes, "counter.replay");
var history = new History(source);
history.Seek(7);
Console.WriteLine($"Step: {history.CurrentStep}");
Console.WriteLine($"Hash: {history.StateHash:x16}");
history.Seek(2);
history.Seek(7);
Console.WriteLine($"After rewind: {history.StateHash:x16}");
var saved = new ArrayBufferWriter<byte>();
source.WriteReplay(saved);
Console.WriteLine($"Replay round trip: {bytes.AsSpan().SequenceEqual(saved.WrittenSpan)}");

public sealed class CounterModule : IScopeModule
{
    public string Id => "counter";
    public string Name => "Counter";
    public IScopeSource Open(ReadOnlyMemory<byte> data, string name)
    {
        if (data.Length != 8 || !data.Span[..4].SequenceEqual("CNTR"u8))
            throw new ScopeFormatException(name, "Expected CNTR and an int32 step count (8 bytes).");
        int steps = BinaryPrimitives.ReadInt32LittleEndian(data.Span[4..]);
        if (steps < 0 || steps > 10000)
            throw new ScopeFormatException(name, "Expected 0..10000 steps at offset 4.");
        var inputs = new ReadOnlyMemory<byte>[steps];
        return new CounterSource(name, Recording.Create(ReadOnlyMemory<byte>.Empty, inputs));
    }
}

public sealed class CounterSource(string name, Recording recording) : IScopeSource
{
    public SourceInfo Info { get; } = new()
    {
        ModuleId = "counter", Name = name, Dimensions = 2,
        Unit = LengthUnit.Metre,
        Axes = [new AxisInfo(AxisIds.Time, "Time", 0)],
        EntityKinds = [new EntityKindInfo("counter", "Counter")],
        Overlays = [new OverlayInfo(0, "Counter", true)]
    };
    public Recording Recording { get; } = recording;
    public IScopeWorld CreateWorld() => new CounterWorld();
    public IScopeSource WithRecording(Recording other) => new CounterSource(name, other);
    public void WriteReplay(IBufferWriter<byte> writer)
    {
        Span<byte> bytes = writer.GetSpan(8)[..8];
        "CNTR"u8.CopyTo(bytes);
        BinaryPrimitives.WriteInt32LittleEndian(bytes[4..], checked((int)Recording.StepCount));
        writer.Advance(8);
    }
}

public sealed class CounterWorld : IScopeWorld
{
    public long Step { get; private set; }
    public ulong Hash() => (ulong)Step; // This toy's entire state is its counter.
    public void Advance(ReadOnlySpan<byte> input, TimelineSink sink)
    {
        if (!input.IsEmpty) throw new ScopeFormatException("counter", "Expected empty step input.");
        Step = checked(Step + 1);
    }
    public void AdvanceAxis(string axis, int position, ReadOnlySpan<byte> input) =>
        throw new ArgumentException("No secondary axes.", nameof(axis));
    public void WriteSnapshot(IBufferWriter<byte> writer)
    {
        BinaryPrimitives.WriteInt64LittleEndian(writer.GetSpan(8), Step);
        writer.Advance(8);
    }
    public void Restore(ReadOnlySpan<byte> snapshot)
    {
        if (snapshot.Length != 8 || BinaryPrimitives.ReadInt64LittleEndian(snapshot) < 0)
            throw new ScopeFormatException("counter", "Expected a nonnegative int64 snapshot.");
        Step = BinaryPrimitives.ReadInt64LittleEndian(snapshot);
    }
    public void CollectEntities(List<EntityInfo> into)
    {
        into.Clear();
        into.Add(new(new EntityId(1), default, "counter", "Counter"));
    }
    public bool Inspect(EntityId entity, PropertyList into)
    {
        into.Clear();
        if (entity.Value != 1) return false;
        into.Add("State", "Value", ScopeValue.OfInteger(Step));
        return true;
    }
    public void Draw(DrawList into, in DrawRequest request) =>
        into.Point(new Vector3(Step, 0, 0), 8, new DrawStyle(Color32.White, Entity: new EntityId(1)));
    public void EncodePoke(in PokeCall call, IBufferWriter<byte> into) =>
        throw new ArgumentException("This counter declares no pokes.");
    public Difference? FirstDifference(IScopeWorld other)
    {
        if (other is not CounterWorld counter) throw new ArgumentException("Expected a counter.");
        return Step == counter.Step ? null : new Difference(new EntityId(1), "value",
            Step.ToString(System.Globalization.CultureInfo.InvariantCulture),
            counter.Step.ToString(System.Globalization.CultureInfo.InvariantCulture));
    }
}
```

```text
Step: 7
Hash: 0000000000000007
After rewind: 0000000000000007
Replay round trip: True
```

`CounterModule.Open` throws `ScopeFormatException` for the wrong magic, size, or step count; the example accepts only zero through 10,000 steps.

## Load it in the viewer

Install the shell separately from the SDK:

```shell
dotnet tool install --global RecursiveAI.OneScope.Tool
```

From the download folder after `dotnet run`, load the assembly:

```shell
onescope open counter.replay   --module bin/Debug/net10.0/project.dll
```

The counter appears as one entity, with its current value in the inspector and a point whose horizontal position is the count.

## Variations

Inspect the counter without opening a window:

```shell
onescope open counter.replay   --module bin/Debug/net10.0/project.dll   --headless --run 'seek {"step":7}'   --run 'inspect {"entity":1}'
```

```json
{"source":0,"step":7,"entity":1,"kind":"counter","name":"Counter","properties":[{"group":"State","name":"Value","kind":"integer","value":7}]}
```

## Related recipes

- [Visualize a navigation mesh and crowds](https://recursiveai.org/onescope/cookbook/visualize-navmesh-and-crowds/)
- [Attach to a running physics game](https://recursiveai.org/onescope/cookbook/attach-to-a-running-game/)

## The short answer

Reference `RecursiveAI.OneScope.Sdk` and implement `IScopeModule`, `IScopeSource`, and `IScopeWorld`. The module recognizes files, the source describes the recording, and the world reconstructs and exposes the simulation state. Build the assembly and load it with `onescope open file --module path/to/Module.dll`.

The counter example is intentionally a complete, small format rather than an adapter for a large engine. Its entire state is the step counter, so its hash is just that value. That identity would not be a sufficient state hash for a physics or navigation world with many independent fields. For a real library, return the library’s own hash of its complete state, including the step.

## How do I build a custom replay viewer in C#?

Separate the file reader from the world. `CounterModule.Open` checks the bytes before allocating the input array. Its format has a fixed length, magic bytes, and a bounded count. A real format needs corresponding checks for every length, count, and arithmetic operation. Throw `ScopeFormatException` for bytes your module does not recognize, because the shell offers files to loaded modules until one accepts them.

`CounterSource` declares the stable module ID, display name, dimensionality, length unit, time axis, and entity kinds. Those declarations let the shell create the tree and inspector without knowing the counter’s implementation. Its recording contains one empty input per step. Empty is a meaningful input here: there are no orders or pokes, and each step always increments by one.

`CounterWorld` implements the state-dependent work. `CollectEntities` clears and refills the entity tree; `Inspect` returns property rows for a known entity. `Draw` emits a neutral point in world units instead of making graphics calls. These are independent contracts: an entity can have inspectable properties even if its drawing is simple.

The SDK does not require your simulation library to reference the viewer. Keep the module alongside your library and adapt the library’s existing replay reader and deterministic stepping API. The [module-authoring guide](https://recursiveai.org/onescope/docs/module-authoring/) describes the contracts and the [API reference](https://recursiveai.org/onescope/docs/api/) lists their public members.

## How does a replay viewer control time?

A seek reconstructs a state from a snapshot and the recorded step inputs. Your `WriteSnapshot` and `Restore` must therefore preserve everything needed for subsequent stepping, not only what is currently visible. The counter writes its nonnegative 64-bit step value. Restoring that value restores the whole toy world; a real world may also need velocities, sleeping flags, random-generator state, and other library-owned state.

The example seeks to seven, back to two, then to seven again. Both visits return the same state hash. That illustrates the obligation rather than adding another clock: OneScope time is an integer step index, and presentation speed does not redefine the simulation’s time step.

`WithRecording` supplies the same source over another recording, which is how a fork gets its own input history. `WriteReplay` must write the source’s recording in the library’s format. Here the count is enough because every input is empty. If you add a poke, you must extend both the step encoding and the replay writer; otherwise saving a changed run would discard its orders.

The example declares no secondary axes or pokes and rejects those calls explicitly. Additional features are opt-in: declare overlays, timeline tracks, fields, and poke parameters only when the world implements them. Live attachment is also separate from file opening; it requires `ILiveModule` and an `ILiveSource` that can append incoming records. A working file module does not automatically make an arbitrary running program attachable.
