<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Halil Coşgun</title>
    <description>The latest articles on DEV Community by Halil Coşgun (@halil_cogun).</description>
    <link>https://dev.to/halil_cogun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4108682%2F28db5d76-1128-4571-8b1f-d82475a7c5b4.jpg</url>
      <title>DEV Community: Halil Coşgun</title>
      <link>https://dev.to/halil_cogun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/halil_cogun"/>
    <language>en</language>
    <item>
      <title>Before You Hunt a Desync, Prove Your Own Simulation Is Deterministic</title>
      <dc:creator>Halil Coşgun</dc:creator>
      <pubDate>Sat, 12 Sep 2026 14:16:49 +0000</pubDate>
      <link>https://dev.to/halil_cogun/before-you-hunt-a-desync-prove-your-own-simulation-is-deterministic-4igf</link>
      <guid>https://dev.to/halil_cogun/before-you-hunt-a-desync-prove-your-own-simulation-is-deterministic-4igf</guid>
      <description>&lt;h2&gt;
  
  
  The test nobody runs first
&lt;/h2&gt;

&lt;p&gt;When the system catches a desync between two players, the match dies, someone files a bug, and the hunt begins. Which client was wrong? Which subsystem? Which tick? Two machines, two builds, two network stacks, two sets of hardware, and somewhere in there one value went a different way.&lt;/p&gt;

&lt;p&gt;The hardest part of a desync is the hunt itself. What makes it worse is that a large share of those bugs were never about the second machine at all. The simulation was not deterministic on one machine either, and nobody had checked.&lt;/p&gt;

&lt;p&gt;There is a test for that. It needs one computer, one recording, and one command. I call it the self-check, and it is the first thing I would run on any simulation that claims to be deterministic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the self-check actually is
&lt;/h2&gt;

&lt;p&gt;Determinism means a simple thing: same starting state, same inputs, same result. Every time. On any machine.&lt;/p&gt;

&lt;p&gt;The multi-machine version of that promise is what lockstep netcode depends on. But there is a weaker version hiding inside it, and the weaker version is much easier to test:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Same starting state, same inputs, same result, &lt;strong&gt;on the same machine, twice in a row.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your simulation cannot pass that, it will never pass the hard version. And unlike the hard version, you can test it alone, offline, in a few seconds, before any player is involved.&lt;/p&gt;

&lt;p&gt;The procedure is three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Play a session and record it. Tickwise writes the inputs and a hash of every tick into a &lt;code&gt;.rec&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;Feed those same recorded inputs back through your simulation and record that run too.&lt;/li&gt;
&lt;li&gt;Compare the two recordings.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tickwise compare original.rec replayed.rec
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the verdict is anything but identical, your simulation is not deterministic, and you just found out before your players did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this catches so much
&lt;/h2&gt;

&lt;p&gt;The interesting part is what fails this test. Almost none of it involves the network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Iteration order.&lt;/strong&gt; You store entities in a &lt;code&gt;HashMap&lt;/code&gt; and iterate it during the update. Rust randomizes hash seeds per process, so the second run walks the same entities in a different order. If anything in that loop is order-sensitive, and in a physics step it usually is, the two runs diverge. The same class of bug exists in every language with unordered containers, and it is one of the four chaos modes shipped with Tickwise for exactly this reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time leaking into the simulation.&lt;/strong&gt; Somewhere deep in a subsystem, someone reads the wall clock or a frame delta instead of the fixed tick. The first run took 16.2 milliseconds on that frame, the replay took 15.9, and the simulation quietly took a different branch. This one hides well, because on a fast machine the numbers look close enough to be invisible until they are not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Uninitialized or stale state.&lt;/strong&gt; A scratch buffer that is not cleared between ticks, a value carried over from the previous run, a lazily built cache that exists on the second pass but not the first. The replay starts from a slightly different world than the recording did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Global mutable state.&lt;/strong&gt; A static counter, a shared random generator, a data or system that survives between sessions. The first run leaves fingerprints that the second run reads.&lt;/p&gt;

&lt;p&gt;Every one of these fails on a single machine. None of them needs a second client, a network, or another player. Which means every one of them can be found before a match is ever played.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching it fail on purpose
&lt;/h2&gt;

&lt;p&gt;Tickwise ships with a reference simulation and a &lt;code&gt;--chaos&lt;/code&gt; flag that injects a known class of non-determinism at a tick you choose. That flag exists so the tool can prove it works, and it doubles as a way to see the self-check fail without breaking your own code first.&lt;/p&gt;

&lt;p&gt;Record a clean session, then record a second one with chaos turned on from tick 4021:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo run &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; tickwise-refsim &lt;span class="nt"&gt;--example&lt;/span&gt; record_demo &lt;span class="nt"&gt;--&lt;/span&gt; clean.rec
cargo run &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; tickwise-refsim &lt;span class="nt"&gt;--example&lt;/span&gt; record_demo &lt;span class="nt"&gt;--&lt;/span&gt; chaotic.rec &lt;span class="nt"&gt;--chaos&lt;/span&gt; stale-value 4021
tickwise compare clean.rec chaotic.rec
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  verdict        first divergence at tick 4021, caught by the light hash,
                 confirmed by the full hash at tick 4200, last agreement at tick 4020
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two recordings that should have been identical, one exact tick where they stopped being identical. That is the whole shape of the self-check, whether the cause is a deliberate chaos flag or a real bug in your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stricter version
&lt;/h2&gt;

&lt;p&gt;Comparing two recordings tells you that a divergence happened. There is a second mode that tells you the moment it happens, while the replay is still running.&lt;/p&gt;

&lt;p&gt;When you replay a recording, Tickwise can verify every tick against the hashes stored in the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;rep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Replayer&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"session.rec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ReplayConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;verify_hashes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="nn"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rep&lt;/span&gt;&lt;span class="nf"&gt;.next_step&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;my_sim&lt;/span&gt;&lt;span class="nf"&gt;.apply_inputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="nf"&gt;.inputs&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;my_sim&lt;/span&gt;&lt;span class="nf"&gt;.tick&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;rep&lt;/span&gt;&lt;span class="nf"&gt;.after_tick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;verify_hashes&lt;/code&gt; on, the replay fails at the first tick where the live hash does not match the recorded one. No second file, no comparison step, no waiting until the end. This is the version worth wiring into CI: record one canonical session, commit it, and replay it on every push. The day someone adds a &lt;code&gt;HashMap&lt;/code&gt; iteration to the physics step, the build tells them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the self-check stops
&lt;/h2&gt;

&lt;p&gt;Honesty about limits, because a test that overpromises is worse than no test.&lt;/p&gt;

&lt;p&gt;The self-check proves that your simulation is deterministic &lt;strong&gt;on one machine, with one build, in one process.&lt;/strong&gt; That is the floor, not the ceiling. Passing it does not mean two different machines will agree. Cross-platform float behavior, compiler flags, CPU differences, and thread counts all live above this line, and the multi-machine comparison is what covers them.&lt;/p&gt;

&lt;p&gt;It also only covers what your hashes cover. If a field is not in &lt;code&gt;light_hash&lt;/code&gt; or &lt;code&gt;full_hash&lt;/code&gt;, the self-check cannot see it drift. This is why Tickwise reports which hash caught a divergence: when the full hash fires and the light hash saw nothing, you have learned something about your hash coverage, not just about your bug. The repository has a checklist for that question.&lt;/p&gt;

&lt;p&gt;So the self-check is not the whole story. It is the cheapest part of the story, and the part almost everyone skips.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start here
&lt;/h2&gt;

&lt;p&gt;If you are building anything that depends on determinism, whether that is lockstep netcode, rollback, replay files, or a deterministic test suite, run the weak version of the promise first. One machine, one recording, one command.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo &lt;span class="nb"&gt;install &lt;/span&gt;tickwise-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository is at &lt;a href="https://github.com/cosgunhalil/Tickwise" rel="noopener noreferrer"&gt;github.com/cosgunhalil/Tickwise&lt;/a&gt;, dual licensed MIT or Apache-2.0. The &lt;a href="https://github.com/cosgunhalil/Tickwise/blob/main/docs/tutorial.md" rel="noopener noreferrer"&gt;tutorial&lt;/a&gt; walks through the full workflow in about fifteen minutes, and the &lt;a href="https://github.com/cosgunhalil/Tickwise/blob/main/docs/hash-coverage.md" rel="noopener noreferrer"&gt;hash coverage checklist&lt;/a&gt; answers the question this post keeps circling: what belongs in each hash.&lt;/p&gt;

&lt;p&gt;If you run the self-check on your own simulation and it fails, I would genuinely like to hear what caught it. Those stories are how the chaos mode list grows.&lt;/p&gt;

&lt;p&gt;Thank you for finding this worth your time.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>gamedev</category>
      <category>netcode</category>
      <category>testing</category>
    </item>
    <item>
      <title>Find Your First Desync in 15 Minutes</title>
      <dc:creator>Halil Coşgun</dc:creator>
      <pubDate>Sat, 05 Sep 2026 16:15:59 +0000</pubDate>
      <link>https://dev.to/halil_cogun/find-your-first-desync-in-15-minutes-14df</link>
      <guid>https://dev.to/halil_cogun/find-your-first-desync-in-15-minutes-14df</guid>
      <description>&lt;h2&gt;
  
  
  The bravest corner of game networking
&lt;/h2&gt;

&lt;p&gt;One corner of game networking always struck me as braver than the rest: fighting games. Multiplayer architecture was my main interest for most of my career, before mobile gaming pulled me toward other problems, and I never picked a favorite between authoritative servers, relay servers, and peer-to-peer. Each fits some game and not the game next door. But fighting games ask for something the others do not.&lt;/p&gt;

&lt;p&gt;A fighting game cannot wait for a server round trip before showing the local player's own punch. One extra frame between the button and the visible response and the game feels wrong, and players can tell. So that community built on deterministic simulation synchronized by inputs: every machine runs the same simulation from the same inputs and is expected to reach the same state. Two ways of handling latency sit on top. Delay-based netcode holds inputs for a few frames so everyone has them in time. Rollback netcode predicts the remote player's input, keeps going, and when the prediction turns out wrong it rewinds and replays those frames before you notice.&lt;/p&gt;

&lt;p&gt;The more I read about this, the braver it looked. You are betting the match on two machines reaching exactly the same state, tick after tick, down to the bit. Floating-point behavior, iteration order, random-number generation: any of them can break the agreement.&lt;/p&gt;

&lt;p&gt;A pattern kept appearing in the articles I read. The bet loses. A desync. Two players are suddenly in different realities, and the author describes a long hunt. The Factorio team put it best in an early &lt;a href="https://www.factorio.com/blog/post/fff-63" rel="noopener noreferrer"&gt;Friday Facts&lt;/a&gt; post about their desync fixing: "You never know if the thing you just solved is the last one, or there are 1500 more waiting." Riot's &lt;a href="https://technology.riotgames.com/news/determinism-league-legends-introduction" rel="noopener noreferrer"&gt;series on making League of Legends deterministic&lt;/a&gt; describes an entire team built around the same fight.&lt;/p&gt;

&lt;p&gt;I don't have that story. I have never shipped a lockstep or rollback game, and I have never lost that week. What I had was curiosity, and a suspicion: the studios doing this must have built some version of the same three tools for themselves. Record the inputs and a hash of every tick. Find the first tick where two recordings disagree. When the run reproduces, dump and diff the game state at that tick. I went looking for an open version of that toolkit that I could use with my own game and could not find one. So I built it, partly to learn Rust properly, and partly because writing the debugger for a problem is the most honest way I know to understand the problem.&lt;/p&gt;

&lt;p&gt;It's called Tickwise. Here's what it does, and how to trace an injected desync with it in the next fifteen minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a desync costs a week
&lt;/h2&gt;

&lt;p&gt;Most bugs tell you where they are. A null reference points at a line. A crash may leave a useful stack trace. A desync can leave neither, and three reasons compound to make it expensive.&lt;/p&gt;

&lt;p&gt;The symptom shows up far from the cause. Say one client rounds a physics value differently at tick 4,021. Nothing visible happens. The two simulations are a hair apart and stay that way until the difference reaches something that matters: a projectile that hits on one screen and misses on the other. By the time a human notices, the match is minutes past the moment that mattered, and nothing on screen points back to it.&lt;/p&gt;

&lt;p&gt;Reproduction is most of the cost. Reproducing a desync means replaying the exact inputs, in the exact order, with the exact random seed. Without them the exact run is usually unrecoverable, and what reaches you is a report from a player in another country that says "it desynced around the fourth round, I think."&lt;/p&gt;

&lt;p&gt;And the tooling, as far as I can see from the outside, tends to stay where it was born. I want to be careful here, because I have not worked inside these studios and most say little about their internals. What I can see is the public record: postmortems, conference talks, engine documentation. They describe the same three ideas again and again. But when I looked for those three things as a small package I could download and use with my own game, engine-agnostic, with replay verification and field-level diffs, I found the workflow inside one commercial engine and otherwise not at all. Excellent versions may well exist behind closed doors. In the public tools I reviewed, the ideas are shared and the code is not.&lt;/p&gt;

&lt;p&gt;That gap is what made me want to try something. The game-specific parts of the problem look small to me: how to hash your state, and how to describe it as fields. If those two things are all a game has to provide, the recorder, the comparison, and the diff might live outside any engine and any project, built once, in the open.&lt;/p&gt;

&lt;p&gt;So here is the belief I would like to break. Debugging a desync is not inherently a week of print statements. It is a week when the recording and comparison tooling is not there. Once the inputs and hashes are on disk, finding the first divergent tick is a comparison, not an investigation, and pointing at the field that diverged is one more command.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Tickwise is, in one screen
&lt;/h2&gt;

&lt;p&gt;Tickwise is a Rust library and a command line tool. The library sits inside your game loop and watches. It never runs your simulation and never touches your netcode. You call it once per tick, it writes a file, and the command line tool reads those files later.&lt;/p&gt;

&lt;p&gt;The entire contract between your game and Tickwise is one trait with three methods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;trait&lt;/span&gt; &lt;span class="n"&gt;DeterminismProbe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/// Called every tick. Must be cheap: a digest of the state most&lt;/span&gt;
    &lt;span class="cd"&gt;/// likely to reveal a desync, not the whole world.&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;light_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/// Called every N ticks. Should cover everything that can&lt;/span&gt;
    &lt;span class="cd"&gt;/// influence a future tick.&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;full_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/// Called only during replay, at the ticks you ask for. May be&lt;/span&gt;
    &lt;span class="cd"&gt;/// expensive. Returns the state as a flat list of named fields.&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;state_dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;StateDump&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The split between a light hash and a full hash is the design idea I would keep if everything else were thrown away. Hashing the entire state every tick can be too expensive in a large simulation, so you hash a small digest every tick and the whole thing every few hundred ticks. The light hash catches a desync the moment it touches something in the digest. The full hash catches divergences the light hash was not looking at, and in that case Tickwise reports the window rather than pretending to know the tick. A first divergent tick is exact for state the light hash covers, and a bounded interval for state only the full hash covers. The tool says which of the two you got.&lt;/p&gt;

&lt;p&gt;If your state type already derives &lt;code&gt;serde::Serialize&lt;/code&gt;, you do not write the trait at all. An automatic probe hashes the serialized bytes and turns the type into named fields by walking its structure. The hand-written trait is there for when you want control over the cost, and for the engine bridges coming later, since three plain functions cross a language boundary and a generic trait does not.&lt;/p&gt;

&lt;p&gt;The workflow has two passes, and the command line tool has one command for each plus one for looking inside a file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tickwise inspect session.rec     &lt;span class="c"&gt;# what is in a recording&lt;/span&gt;
tickwise compare a.rec b.rec     &lt;span class="c"&gt;# the first tick where two recordings disagree&lt;/span&gt;
tickwise diff a.dump b.dump      &lt;span class="c"&gt;# which fields differ at that tick, and how&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pass one, while you play&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every client records inputs and per-tick hashes into a &lt;code&gt;.rec&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tickwise compare a.rec b.rec&lt;/code&gt; reports the first tick where the recorded hashes disagree, and which hash caught it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pass two, afterwards&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Replay each recording in your own loop, dumping the state at the reported tick into a &lt;code&gt;.dump&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tickwise diff a.dump b.dump&lt;/code&gt; lists every field that differs, labeled structural, exact, or a float that drifted by less than your chosen epsilon.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The diff classifies rather than judges, because whether floats belong in your simulation is your decision.&lt;/p&gt;

&lt;p&gt;Since "cheap" means nothing in a frame budget without a number, here are the measured ones, from the criterion benchmarks in the repository on a desktop machine. The recorder itself costs about 20 nanoseconds per tick with steady inputs and about 100 when inputs change every tick, because it encodes into a reused buffer and writes through a buffered file. The reference simulation's hand-written light hash takes 32 nanoseconds regardless of world size, which is just under one percent of that simulation's 3.3 microsecond tick at a thousand entities. Its full hash takes 14 microseconds and runs every few hundred ticks. On disk, the 6000 tick demo recording is 283 KiB, about 48 bytes per tick with inputs that change constantly, and far less when players hold a direction. The automatic serde probe is the one to measure yourself, since it serializes your whole state on every call and its cost scales with that; the repository's budget guide explains how.&lt;/p&gt;

&lt;p&gt;What Tickwise is not, so nobody is disappointed later: it is not netcode, not a rollback engine, and it will not make your simulation deterministic for you. It tells you where determinism broke. Fixing it is still your job.&lt;/p&gt;

&lt;p&gt;A note on how it was built, since I want to be upfront about it. I developed Tickwise with Claude, Anthropic's AI, as a programming partner. I wrote the design document, made the architectural and licensing decisions, and reviewed and committed every change. Claude produced much of the initial code and pushed back on several of my decisions, sometimes correctly. That workflow, including where it went wrong, deserves its own post, and it will get one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catch a bug with me
&lt;/h2&gt;

&lt;p&gt;Enough description. Let's break something and find it.&lt;/p&gt;

&lt;p&gt;The repository ships a small reference simulation: a few balls bouncing around an arena, two players nudging them, a score that goes up on every bounce. It is deliberately boring and deterministic to the bit. It also has a &lt;code&gt;--chaos&lt;/code&gt; flag that sabotages it on purpose with a controlled model of one of four classic desync-producing bugs, starting at a tick you choose. Today's bug is &lt;code&gt;stale-value&lt;/code&gt;: from the chosen tick on, the simulation reads a scratch value left over from the previous tick, one that should have been reset and was not, and folds it into the score. Safe Rust cannot read truly uninitialized memory, so this is a simulation of the stale-cache bug as it actually appears in production: the value is initialized, it is simply from the wrong moment. It is the kind of thing that passes code review because the code looks fine.&lt;/p&gt;

&lt;p&gt;You need Rust installed and a few minutes of compile time on the first run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo &lt;span class="nb"&gt;install &lt;/span&gt;tickwise-cli
git clone https://github.com/cosgunhalil/Tickwise.git
&lt;span class="nb"&gt;cd &lt;/span&gt;Tickwise
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Record two sessions. Same seed, same inputs, six thousand ticks each. The second one carries the bug from tick 4021.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo run &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; tickwise-refsim &lt;span class="nt"&gt;--example&lt;/span&gt; record_demo &lt;span class="nt"&gt;--&lt;/span&gt; clean.rec
cargo run &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; tickwise-refsim &lt;span class="nt"&gt;--example&lt;/span&gt; record_demo &lt;span class="nt"&gt;--&lt;/span&gt; chaotic.rec &lt;span class="nt"&gt;--chaos&lt;/span&gt; stale-value 4021
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now pretend these came from two players' machines and you have no idea what happened. Ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tickwise compare clean.rec chaotic.rec
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  first          6000 ticks, game tickwise-refsim, seed 0xddba11
  second         6000 ticks, game tickwise-refsim, seed 0xddba11

  verdict        first divergence at tick 4021, caught by the light hash, confirmed by the full hash at tick 4200, last agreement at tick 4020

  next           Pass 2: replay each recording in your own loop with
                 dump_at_ticks = [4021] to produce two .dump files, then run
                 tickwise diff a.dump b.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is pass one, and it took a few milliseconds. The last line is deliberate: the output says what to do next, because nobody reads documentation with a desync open in another window.&lt;/p&gt;

&lt;p&gt;Pass two. Replay both recordings and dump the state at tick 4021. The replayer checks every recorded hash against the live simulation as it goes, so if your simulation could not reproduce its own recording, you would find out here, before hunting anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo run &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; tickwise-refsim &lt;span class="nt"&gt;--example&lt;/span&gt; replay_demo &lt;span class="nt"&gt;--&lt;/span&gt; clean.rec clean.dump &lt;span class="nt"&gt;--dump-at&lt;/span&gt; 4021
cargo run &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; tickwise-refsim &lt;span class="nt"&gt;--example&lt;/span&gt; replay_demo &lt;span class="nt"&gt;--&lt;/span&gt; chaotic.rec chaotic.dump &lt;span class="nt"&gt;--dump-at&lt;/span&gt; 4021 &lt;span class="nt"&gt;--chaos&lt;/span&gt; stale-value 4021
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wrote clean.dump with the state at tick 4021
replayed ticks 0 to 5999, every hash matched the recording
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing to be clear about, because it is the hardest part of a real desync. This example is deliberately reproducible: passing the same chaos mode during replay recreates the faulty execution. A genuinely nondeterministic bug, a race, unrecorded external state, platform-specific float behavior, may fail that hash verification instead. In that case Tickwise still identifies the first recorded divergence, but it cannot reconstruct the original state from inputs alone, and the field-level diff is out of reach until you either reproduce the divergent execution or capture state during the original run. The tutorial shows exactly this happening with two of the other chaos modes. Recording periodic state dumps during play, so that two machines' dumps can be diffed without reproducing anything, is the natural next step for the recorder, and it is on the list.&lt;/p&gt;

&lt;p&gt;And the question we actually care about: what is different?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tickwise diff clean.dump chaotic.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tick 4021       1 difference over 41 fields: 0 structural, 1 exact, 0 sub-epsilon float drift
  exact          score: 3317 versus 4811663725493808200

  verdict        1 difference across 1 compared tick
  next           an exact difference at the first divergent tick is your lead. Trace that field's last write backwards through the tick
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One field out of forty-one. The score on one side is 3317. On the other it is a nineteen-digit number, which immediately suggests that an invalid or stale value entered the score calculation. The other forty fields agree. The first differing field is not automatically the root cause, since a corrupted random state or a reordered loop can surface somewhere downstream, but it is a sharply narrowed lead: the writes that can affect &lt;code&gt;score&lt;/code&gt; during tick 4021. In this simulation that is one function.&lt;/p&gt;

&lt;p&gt;Four commands. The full tutorial in the repository walks through this with more explanation, then through the other three chaos modes, because each teaches something different.&lt;/p&gt;

&lt;p&gt;The one worth previewing is &lt;code&gt;float-drift&lt;/code&gt;, which nudges one ball's velocity by a single bit every tick from the strike onward. Run the same steps and &lt;code&gt;compare&lt;/code&gt; says something new:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  verdict        divergence caught by the full hash at tick 4200, while the light hash saw nothing: the light hash has a blind spot, and the real divergence happened at or before this tick, last agreement at tick 3900
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The light hash in this simulation covers the score, the random state, and the player positions, not ball velocities, so a one-bit velocity change is invisible to it. The full hash fires at its next scheduled check, tick 4200, and Tickwise tells you plainly that the light hash missed something and the real divergence lies in the 300 ticks before. Dump just after the last agreement and the diff shows one field, one bit, classified as sub-epsilon drift; dump at 4200 and the same field has compounded into an exact difference. A light hash is only as good as what you put in it, and the repository has a checklist for exactly that question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things I learned building it
&lt;/h2&gt;

&lt;p&gt;This project was partly an excuse to learn Rust properly, and partly an excuse to make a few mistakes in public and write them down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design for the language you have not written yet.&lt;/strong&gt; From the first day, the plan was that Tickwise would eventually reach Unity and other engines through a C ABI. That one constraint shaped everything. The probe trait has three plain methods and no generics, because three function pointers cross a language boundary and a generic trait does not. The state dump is a flat list of named fields built with insert calls, not a tree, because a sequence of push calls is far simpler to expose across a C ABI than a Rust-owned recursive tree. None of the FFI code exists yet, and the core is already shaped for it. Designing for a second language turned out to be a good way to design a clean API for the first one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A file format that must never panic will find your bugs for you.&lt;/strong&gt; Recordings will come from other people's machines, sometimes half-written when a game crashed. So the rule was that malformed input produces an error, never a panic, and the tests enforce it: one truncates a valid recording at every possible byte length, another flips every single byte, and a fuzzer runs on every push. The truncation sweep found a real bug on the day the format was written: a bounds check on the seek index could underflow on a crafted file and panic in debug builds. It never reached a user, because the test written to catch that class of mistake existed before the code it tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My favorite example did not exist.&lt;/strong&gt; The design document opened with the dream output, &lt;code&gt;players[2].velocity.x: 3.5 vs 3.5000001&lt;/code&gt;, a sub-epsilon float drift. When I wrote the test for it, the compiler's lint pointed out that &lt;code&gt;3.5000001&lt;/code&gt; is not representable as an f32. It rounds to exactly 3.5. The nearest single-precision value above 3.5 is 3.5000002, one unit in the last place away. I had carried a number in my head for weeks that could not exist in the type I was writing about. The diff engine now compares float bits, never decimal strings, and the tests use the real neighbor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it is going
&lt;/h2&gt;

&lt;p&gt;Version 0.2.2 is on crates.io: the recorder, the two file formats, all three commands, the replayer, the serde layer, and two integrations in the repository, one with GGRS's example game and one with the Bones ECS framework. It is the first complete Rust-only release.&lt;/p&gt;

&lt;p&gt;The next step is leaving Rust. First a C ABI: one crate, &lt;code&gt;tickwise-ffi&lt;/code&gt;, built as a shared and a static library with a generated C header, exposing the recorder and the dump builder as plain functions, with prebuilt binaries for Windows, macOS, Linux, Android, and iOS published from CI. Every engine bridge after it will be a thin wrapper over that one surface, which is why it comes first and gets the most careful review.&lt;/p&gt;

&lt;p&gt;Then Unity, as a package you add from the repository URL: a C# interface mirroring the three probe methods, a recorder that works in any loop, and a sample scene with a deterministic mini game and a chaos toggle. I have spent most of my career around Unity, so this is the bridge I care most about getting right, and since the Unity editor cannot run on a CI machine, it will be validated by hand in a real editor before anyone is asked to trust it. Each bridge will start with recording and compare, with replay and dumps following once recording is proven in that engine.&lt;/p&gt;

&lt;p&gt;More engines follow on the same C ABI, in whatever order people actually ask for. No dates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep reading
&lt;/h2&gt;

&lt;p&gt;If this made you curious about the field rather than just the tool, these are the pieces I learned the most from, roughly in reading order.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://gafferongames.com/post/deterministic_lockstep/" rel="noopener noreferrer"&gt;Deterministic Lockstep&lt;/a&gt; by Glenn Fiedler. The clearest explanation of the model, and the source of the standard: "Not close. Not near enough. Exactly the same."&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.gabrielgambetta.com/client-server-game-architecture.html" rel="noopener noreferrer"&gt;Fast-Paced Multiplayer&lt;/a&gt; by Gabriel Gambetta. The authoritative-server side: client-side prediction and server reconciliation. Read it to see what lockstep gives up and gets back.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.gamedeveloper.com/programming/1500-archers-on-a-28-8-network-programming-in-age-of-empires-and-beyond" rel="noopener noreferrer"&gt;1500 Archers on a 28.8&lt;/a&gt; by Paul Bettner and Mark Terrano. The 2001 Age of Empires paper, still one of the best accounts of why lockstep exists.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/pond3r/ggpo" rel="noopener noreferrer"&gt;GGPO&lt;/a&gt; by Tony Cannon. The rollback library that changed fighting games.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arstechnica.com/gaming/2019/10/explaining-how-fighting-games-use-delay-based-and-rollback-netcode/" rel="noopener noreferrer"&gt;Explaining how fighting games use delay-based and rollback netcode&lt;/a&gt; by Ricky Pusch at Ars Technica. The explainer that fixed the vocabulary.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://technology.riotgames.com/news/determinism-league-legends-introduction" rel="noopener noreferrer"&gt;Determinism in League of Legends&lt;/a&gt; by Riot Games. Making an existing engine deterministic after the fact.&lt;/li&gt;
&lt;li&gt;The Factorio team's Friday Facts posts on desyncs, starting with &lt;a href="https://www.factorio.com/blog/post/fff-63" rel="noopener noreferrer"&gt;#63, The endless struggle&lt;/a&gt;. Search the archive for "desync"; few teams have written as candidly about hunting them.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.rs/ggrs" rel="noopener noreferrer"&gt;GGRS&lt;/a&gt;, the Rust rollback library, and its SyncTest session. Tickwise is designed to sit next to it, not replace it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo add tickwise &lt;span class="nt"&gt;--features&lt;/span&gt; serde
cargo &lt;span class="nb"&gt;install &lt;/span&gt;tickwise-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository is at &lt;a href="https://github.com/cosgunhalil/Tickwise" rel="noopener noreferrer"&gt;github.com/cosgunhalil/Tickwise&lt;/a&gt;, dual licensed MIT or Apache-2.0. The &lt;a href="https://github.com/cosgunhalil/Tickwise/blob/main/docs/tutorial.md" rel="noopener noreferrer"&gt;fifteen minute tutorial&lt;/a&gt; is the longer version of what you just read, and the &lt;a href="https://github.com/cosgunhalil/Tickwise/blob/main/docs/hash-coverage.md" rel="noopener noreferrer"&gt;hash coverage checklist&lt;/a&gt; answers the question every reader asks next: what to put in each hash.&lt;/p&gt;

&lt;p&gt;If you have a desync story, or better, a recording of one, the issue tracker has a template for it. Real recordings from real games are the most valuable thing this project can receive right now, and I would rather learn from your bug than from another one of mine.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>gamedev</category>
      <category>netcode</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
