---
title: "View a simulation replay from multiple cameras"
url: https://recursiveai.org/onescope/cookbook/arrange-replay-views/
description: "Arrange OneScope replay views, assign sources, frame a selected entity and show or hide panels. Includes a runnable multiple-camera example."
version: "1.0.0"
---

# View a simulation replay from multiple cameras

**OneScope** is a free, open-source viewer for simulation recordings, with a desktop window or command-line control, from Recursive AI. MIT or Apache-2.0.

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

A replay is a recorded run. A module is the C# code that reads and draws it; its views share time but keep separate camera settings.

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

Requires [.NET 10](https://dotnet.microsoft.com/download/dotnet/10.0) (net10.0); .NET 9 and earlier aren’t supported. The window and the no-window (`headless`) example both need a working graphics renderer; without one, OneScope cannot start.

Download and unzip the example first.

**Window path — build the module, then open the replay:**

```shell
dotnet build -o bin/demo
onescope open sender.replay \
  --module bin/demo/Example.dll \
  --run 'view {"add":"2d"}' \
  --run 'view {"index":2,"halfHeight":3}'
```

`onescope open` shows two 2D views and one 3D view of the same recording, all at step 0. Each camera can be moved independently.

**Console path — run the C# example without a window:**

Unzip [the example](https://recursiveai.org/onescope/cookbook/arrange-replay-views/arrange-replay-views.zip), then `dotnet run`.

## The full program

The example adds a third view, selects an entity (an object in the simulation), fits one camera around it and sets the other cameras independently. All three views end at step 3.

The C# launcher installs OneScope.Tool 1.0.0 in its build folder and loads the module from its own assembly. The recordings and module are included example code, not a bundled OneScope file format.

The included eight-byte inputs are `43 4E 54 52 00 00 00 00` (sender) and `43 4E 54 52 02 00 00 00` (receiver): `CNTR` plus a file-role number. The reader creates ten simulation steps.

```csharp
using System.Buffers;
using System.Buffers.Binary;
using System.Numerics;
using System.Diagnostics;
using System.Text.Json;
using OneScope.Sdk;

// CNTR + little-endian role: 0 stays, 1 sends, 2 receives; ten empty steps.
File.WriteAllBytes("sender.replay", [67, 78, 84, 82, 0, 0, 0, 0]);
File.WriteAllBytes("receiver.replay", [67, 78, 84, 82, 2, 0, 0, 0]);
string assembly = typeof(CounterModule).Assembly.Location;
string toolDir = Path.GetFullPath("obj/onescope-tool");
string tool = Path.Combine(toolDir, OperatingSystem.IsWindows() ? "onescope.exe" : "onescope");
if (!File.Exists(tool))
    Run("dotnet", "tool", "install", "RecursiveAI.OneScope.Tool", "--version", "1.0.0", "--tool-path", toolDir);
var results = Open(
    "seek {\"step\":3}",
    "view {\"add\":\"2d\"}",
    "inspect {\"entity\":1}",
    "view {\"index\":0,\"frameSelection\":true}",
    "view {\"index\":2,\"source\":0,\"target\":[1,1,0],\"halfHeight\":3}",
    "view {\"index\":1,\"distance\":8,\"yaw\":90,\"pitch\":45}",
    "layout {\"entities\":false,\"inspector\":true,\"log\":false}");
var seekResult = results[1];
var cameraResult = results[^2];
Console.WriteLine($"Views: {cameraResult.GetProperty("views").GetArrayLength()}");
Console.WriteLine($"Shared step: {seekResult.GetProperty("step")}");
foreach (var view in cameraResult.GetProperty("views").EnumerateArray()) Console.WriteLine(view);

JsonElement[] Open(params string[] operations)
{
    var args = new List<string> { "open", "sender.replay", "--module", assembly,
        "--headless", "--size", "640x480" };
    foreach (string operation in operations) args.AddRange(["--run", operation]);
    return Run(tool, args.ToArray()).Split('\n', StringSplitOptions.RemoveEmptyEntries)
        .Select(s => JsonDocument.Parse(s).RootElement.Clone()).ToArray();
}
string Run(string command, params string[] arguments)
{
    var start = new ProcessStartInfo(command) { RedirectStandardOutput = true, RedirectStandardError = true };
    foreach (string arg in arguments) start.ArgumentList.Add(arg);
    using var process = Process.Start(start)!;
    var output = process.StandardOutput.ReadToEndAsync();
    var errors = process.StandardError.ReadToEndAsync();
    process.WaitForExit();
    if (process.ExitCode != 0) throw new InvalidOperationException(output.Result + errors.Result);
    return output.Result;
}

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 role (8 bytes).");
        int steps = 10;
        int role = data.Span[4];
        if (role < 0 || role > 2 || data.Span[5..].IndexOfAnyExcept((byte)0) >= 0)
            throw new ScopeFormatException(name, "Expected role 0..2 and zero padding at offset 4.");
        var inputs = new ReadOnlyMemory<byte>[steps];
        return new CounterSource(name, Recording.Create(ReadOnlyMemory<byte>.Empty, inputs), role);
    }
}

public sealed class CounterSource(string name, Recording recording, int role) : IScopeSource
{
    public SourceInfo Info { get; } = new()
    {
        ModuleId = "counter", Name = name, Dimensions = 2,
        Unit = LengthUnit.Metre,
        LinkKey = role != 2 ? "sender" : "receiver",
        Fields = [DemoField.Info],
        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(role);
    public IScopeSource WithRecording(Recording other) => new CounterSource(name, other, role);
    public void WriteReplay(IBufferWriter<byte> writer)
    {
        Span<byte> bytes = writer.GetSpan(8)[..8];
        "CNTR"u8.CopyTo(bytes);
        BinaryPrimitives.WriteInt32LittleEndian(bytes[4..], role);
        writer.Advance(8);
    }
}

public sealed class CounterWorld(int role) : 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);
        if (Step == 3) sink.Finding("warm", Severity.Warning, [new EntityId(1)], "Temperature reached 3.", "Check the heat input.");
        if (Step == 7) sink.Finding("hot", Severity.Error, [new EntityId(1)], "Temperature reached 7.");
    }
    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();
        if (Present) into.Add(new(new EntityId(1), default, "counter", "Counter"));
    }
    public bool Inspect(EntityId entity, PropertyList into)
    {
        into.Clear();
        if (entity.Value != 1 || !Present) return false;
        into.Add("State", "Value", ScopeValue.OfInteger(Step));
        return true;
    }
    bool Present => role == 0 || (role == 1 ? Step < 5 : Step >= 5);
    public void CollectLinks(List<EntityLink> into)
    {
        into.Clear();
        if (role == 0) return;
        into.Add(new(new EntityId(1), role == 1 ? LinkDirection.ContinuedBy : LinkDirection.Continues,
            role == 1 ? "receiver" : "sender", new EntityId(1), 5));
    }
    public bool TryReadField(string field, EntityId entity, out double value)
    {
        value = Step;
        return field == "heat" && entity.Value == 1 && Present;
    }
    public bool TryReadFieldAt(string field, Vector3 point, out double value)
    {
        value = Step;
        return field == "heat";
    }
    public void Draw(DrawList into, in DrawRequest request)
    {
        if (!Present) return;
        into.FieldMesh([new(0, 0, 0), new(2, 0, 0), new(0, 2, 0)],
            [0, 1, 2], [(float)Step, (float)Step, (float)Step],
            FieldDomain.Vertex, DemoField.Info,
            new DrawStyle(Color32.White, Entity: new EntityId(1)));
        into.FieldPoints([new Vector3(1, 1, 0)], [(float)Step], DemoField.Info,
            12, 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));
    }
}

public static class DemoField
{
    public static readonly FieldInfo Info = new("heat", "Temperature", ColorMap.Heat, 0, 10, "degrees");
}
```

`dotnet run` prints:

```text
Views: 3
Shared step: 3
{"index":0,"kind":"2d","source":0,"wireframe":false,"grid":true,"target":[1,1,0],"halfHeight":1.12}
{"index":1,"kind":"3d","source":0,"wireframe":false,"grid":true,"target":[1,1,0],"distance":8,"yaw":90,"pitch":45}
{"index":2,"kind":"2d","source":0,"wireframe":false,"grid":true,"target":[1,1,0],"halfHeight":3}
```

Adding a fifth view fails with `rejected`; the shell supports at most four views.

## Variations

### Frame selection and hide the log

Run from the same example folder:

```shell
dotnet build -o bin/demo
onescope open sender.replay \
  --module bin/demo/Example.dll \
  --run 'seek {"step":3}' \
  --run 'inspect {"entity":1}' \
  --run 'view {"index":0,"frameSelection":true}' \
  --run 'view {"index":1,"distance":8,"yaw":90}' \
  --run 'layout {"log":false,"inspector":true}'
```

The final operation returns:

```json
{"entities":true,"inspector":true,"timeline":true,"log":false,"compare":false}
```

For your own replay format, [write a custom module](https://recursiveai.org/onescope/cookbook/write-a-onescope-module/) so OneScope knows how to read and draw it.

## Related recipes

- [Visualize simulation scalar fields in C#](https://recursiveai.org/onescope/cookbook/visualize-scalar-fields/)
- [Track an entity across replay sources](https://recursiveai.org/onescope/cookbook/track-entity-between-sources/)
- [Inspect simulation replay entity properties](https://recursiveai.org/onescope/cookbook/inspect-replay-entity-properties/)

---

## The short answer

Use `view` to add a 2D or 3D camera, assign its source and adjust its camera parameters. Use `layout` separately to show or hide panels. Views share the simulation’s step cursor but keep separate camera settings. Changing a camera does not move the replay forward or backward.

The example starts with the shell’s two default views, adds another 2D view and gives it a different half-height. The full program also configures the 3D camera’s distance, yaw and pitch, selects an entity and frames it in one view. It ends with all three cameras looking at step 3, rather than three independent simulation times.

## How do I view multiple cameras on one screen?

Add a view with `add: "2d"` or `add: "3d"`, then read the returned view list to obtain its index. The sample’s added view is index 2. Use an existing index for later changes rather than repeatedly adding new views. The shell supports at most four views; adding a fifth is rejected.

Each view names a source. To compare cameras on the same run, assign the same source number to both. To compare different runs, assign different sources while retaining the shared cursor. These are different questions: several cameras show different perspectives, while several sources show different reconstructed worlds. A source does not have to be reopened to get another camera.

The sample module declares a 2D source but the shell also supplies a 3D camera view. Its geometry lies in the XY plane, so an angled 3D camera shows that plane rather than inventing depth. Use a module with genuinely 3D geometry when the investigation needs depth. The camera type does not change the simulation’s dimensionality.

## How do I frame an entity and set independent cameras?

First inspect or select the entity, then request `frameSelection` on the view you want to fit around it. `frame` instead frames the moving bodies. These are fit operations, not ongoing follow modes. Use tracking when you want a camera to re-center on selection at each seek.

For a 2D camera, `target` sets the center and `halfHeight` controls the vertical extent. A larger half-height shows more space. For a 3D camera, use target, distance, yaw and pitch. The full program leaves view 0 tightly framed, gives view 2 a half-height of 3 and sets the 3D view’s distance to 8 with yaw 90 and pitch 45. The printed JSON preserves the difference.

Set camera adjustments after initial framing when reproducing a particular composition. Read the view list rather than assuming all cameras retained a previous automatic fit. View indices are positions in the current list, so removing a view can change the indices you need for subsequent operations. The [operation reference](https://recursiveai.org/onescope/docs/shell/) lists the camera arguments and returned state.

## How do I hide replay panels without changing the timeline?

Use `layout` with booleans for entities, inspector, timeline, log and compare. Hiding the entity tree does not remove the entities or the selected object. Hiding the inspector does not change property values. These options control the shell’s panels, not which parts of the replay are simulated.

Keep the inspector visible when using a camera to investigate one body: its numeric properties provide context that a picture alone cannot. Hide the log when you need more viewing space and are not investigating diagnostics. The example demonstrates both choices without altering the recording.

The shared cursor remains separate from all these display settings. A seek updates the worlds shown by the views; a layout change does not seek. Similarly, changing the timeline’s visible range changes presentation, not the world state. When comparing views, report the selected simulation step as well as the cameras so someone else can reproduce the same state and composition.
