DEV Community

Halil Coşgun
Halil Coşgun

Posted on

Tickwise in Unity

The part I actually cared about

When I started Tickwise, the plan said the Rust core comes first and the engine bridges come later. That order was deliberate. The core had to be shaped for a language boundary it had not crossed yet, so the probe interface stayed at three plain methods with no generics, and the state dump stayed a flat list of named fields instead of a tree. Nothing about the first release needed those constraints. The second one did.

I have spent most of my career in Unity, so this is the bridge I wanted to get right more than any other. It is now in the repository, validated by hand in a real editor, and this post walks through both halves: how to put it in your project, and what the sample proves when you run it.

Figure 1: Unity Package Manager with Tickwise installed

What the bridge actually is

There is no Tickwise engine, no Tickwise loop, no component you drop on a GameObject that takes over your simulation. The package is a thin C# layer over the same native library the Rust crate uses. You keep your own fixed step, and once per tick you hand Tickwise two things: the input bytes for that tick, and an object that can hash your gameplay state.

The whole contract is one interface:

public interface IDeterminismProbe
{
    ulong LightHash();   // every tick, must be cheap
    ulong FullHash();    // every N ticks, must cover everything
}
Enter fullscreen mode Exit fullscreen mode

If you also want the field-level diff, which names the value that diverged rather than just the tick, you implement a second small interface on the same object:

public interface ITickwiseStateWriter
{
    void WriteState(TickwiseDump dump);
}
Enter fullscreen mode Exit fullscreen mode

That is the entire surface you are required to touch. Everything else is configuration.

Installing the package

Install by git URL from the Package Manager window, or add one line to Packages/manifest.json:

"com.cosgunhalil.tickwise": "https://github.com/cosgunhalil/Tickwise.git#unity/v0.1.0"
Enter fullscreen mode Exit fullscreen mode

Released versions live on the upm branch with the native binaries already inside, under tags shaped like unity/vX.Y.Z. That is the path you want. Installing from main is possible but it points at the package sources under bridges/unity/, which carry no binaries, so you would have to build the native library yourself with the script in bridges/tickwise-ffi/scripts/.

Requirements are short: Unity 2022.3 or newer, validated by hand on Unity 6000.3, and the tickwise command line tool for the comparison step. The tool comes from cargo install tickwise-cli or from a release binary in the repository. The comparison happens outside the editor because it operates on two finished recordings, usually from two different machines.

The native library ships per platform: Windows, macOS as a universal binary, Linux, three Android ABIs, and iOS as a static library. The C# side is identical everywhere except iOS, where the import name has to be __Internal because the library is linked statically into the app rather than loaded at runtime.

Import the sample

In the Package Manager window, select Tickwise, open the Samples tab, and import Deterministic Mini Game. You get a scene with one object and one script.

Figure 2: the Samples tab with the Deterministic Mini Game import button

The sample is eight balls bouncing in a box with integer math and no engine physics at all. That is not laziness, it is the point. A deterministic simulation should be a plain class that steps on demand and can hash itself, with rendering layered on top. If your gameplay code needs Time.deltaTime to decide what happens, no recording tool can save it.

public sealed class MiniGameSim : IDeterminismProbe
{
    public void Step(byte input, int chaosNoise) { /* integer physics */ }
    public ulong LightHash() { /* score, rng, tick, position sum */ }
    public ulong FullHash() { /* everything */ }
}
Enter fullscreen mode Exit fullscreen mode

The runner drives it from FixedUpdate with a fixed step of one sixtieth of a second, feeds it scripted inputs rather than the keyboard so both runs see identical input, and records every tick.

Figure 3: Tickwise Sample Runner with Session Name, Ticks To Record, Seed, Inject Chaos and Chaos At Tick

Recording, in about ten lines

Here is the shape of it, lifted from the sample and trimmed:

var config = new RecorderConfig
{
    GameId = "tickwise-mini-game",
    BuildHash = Application.version,
    Platform = Application.platform.ToString(),
    TickRate = 60,
    RngSeed = seed,
    FullHashInterval = 50,
    SnapshotEvery = 300,
}.StampCreatedAt();

_recorder = TickwiseRecorder.Create(path, config);

// in FixedUpdate
_sim.Step(input, chaosNoise);
_recorder.RecordTick(_tick, _input, _sim);
Enter fullscreen mode Exit fullscreen mode

Two details worth pointing out.

BuildHash and Platform go into the recording header, and they matter more than they look. Comparing two recordings from different builds is meaningless, and having the build stamped in the file is what lets you notice that before you spend an afternoon chasing a divergence that was really a version mismatch.

FullHashInterval is set to 50 in the sample rather than the default 300, because the sample only runs 600 ticks. In a real game the default is the better starting point. The light hash runs every tick and needs to stay cheap; the full hash runs occasionally and needs to cover everything. Anything left out of the full hash is a blind spot where a desync can hide, and Tickwise tells you when a divergence was caught by the full hash but missed by the light one, which is information about your hash coverage rather than about your bug.

Recordings land in Application.persistentDataPath, which is the right place: it works in the editor and in a player build, on desktop and on device.

The planted bug

The sample has a chaos toggle, and what it injects is the most realistic non-determinism bug I could think of for an engine context: the wall clock leaking into the simulation.

if (injectChaos && _tick >= (ulong)chaosAtTick)
{
    chaosNoise = 1 + (int)((long)(Time.realtimeSinceStartupAsDouble * 1000.0) % 7);
}
Enter fullscreen mode Exit fullscreen mode

From tick 421 onward, a value derived from real elapsed time is added to the first ball's position. It is never the same on two runs. This is exactly the class of bug that survives code review, because every individual line looks reasonable, and it only shows itself when two machines compare notes.

Run the scene once with Session Name set to clean and Inject Chaos off. Run it again with chaotic and the toggle on. Two files, same seed, same scripted inputs, one difference.

Figure 4: Chaotic run

The moment it pays off

Two recordings, one command:

tickwise compare clean.rec chaotic.rec
Enter fullscreen mode Exit fullscreen mode
  verdict        first divergence at tick 421, caught by the light hash,
                 last agreement at tick 420
Enter fullscreen mode Exit fullscreen mode

Tick 421. Not "somewhere in the second half of the match", not "after the third round". The exact tick, found offline, in milliseconds, from two files.

That number is not a coincidence, and that is the part worth dwelling on. The bug was planted at tick 421 and the tool reports tick 421, because the light hash in this sample covers the ball positions. Drop the position sum from the light hash and the same bug is still caught, but later, when a shifted bounce finally changes the score. Same bug, worse report, and the gap between them is entirely a function of what you chose to hash.

Figure 5: the terminal output of tickwise compare with the verdict lines visible

The bit I am proudest of

The sample scene is validated by hand, which is what I promised in the launch post: editors cannot run on a continuous integration machine, so no one should be asked to trust an engine bridge that was only ever compiled. But I wanted more than a manual checklist, so the validation is also a Unity play mode test that loads the sample scene, runs it twice at twenty times speed, shells out to the command line tool, and asserts on the output:

StringAssert.Contains("first divergence at tick 421", output);
StringAssert.Contains("last agreement at tick 420", output);
Enter fullscreen mode Exit fullscreen mode

A test that fails if the bridge, the native library, the recording format, and the comparison tool ever stop agreeing with each other. The C# wrapper itself is tested separately without an editor at all: the Runtime/ sources have no reference to UnityEngine, so they compile in a plain .NET project and run in CI on Windows, macOS, and Linux.

That separation was not an accident either. A runtime layer that does not depend on the engine is a layer you can test everywhere, and the parts that genuinely need an editor stay small enough to check by hand.

Wiring it into your own game

Three steps, in order of how much they will teach you.

One, find your simulation. If your gameplay state lives across a dozen MonoBehaviours and advances in Update with a variable delta, that is the real work, and Tickwise cannot do it for you. Deterministic multiplayer needs a simulation that steps on a fixed tick and holds its own state. The sample is deliberately shaped to show what that looks like.

Two, implement the probe. Start with a FullHash that covers everything you can serialize, and a LightHash over the few values most likely to reveal a divergence: entity count, player state, the random seed, the score. The repository has a checklist for this question, because it is the one every reader asks second.

Three, record and compare. One line in your tick, two runs, one command. Start with the self-check, which is the same recording compared against a replay of itself on one machine. If your simulation cannot reproduce its own recording, no amount of cross-machine comparison will help yet.

Where this goes

The Unity bridge sits on a C ABI that the other engine bridges share, which is why the repository now also carries Unreal, Godot, Bevy, cocos2d-x, and plain C++ layers over the same native library. They are at different stages of validation and I am working through them one at a time, in real editors, because that is the only honest way to say a bridge works.

The package is at version 0.1.0 and the format is still allowed to break until 1.0. If you try it on a real project, especially one with an existing lockstep or rollback implementation, I would rather hear that it broke than hear nothing.

Repository: github.com/cosgunhalil/Tickwise, dual licensed MIT or Apache-2.0.

Unity tutorial: find your first desync in Unity in 15 minutes.

Thank you for finding this worth your time.

Top comments (0)