<?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: Alex Silaev</title>
    <description>The latest articles on DEV Community by Alex Silaev (@chromealex).</description>
    <link>https://dev.to/chromealex</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%2F4219085%2Fff36875a-650b-4991-80fa-df0c7aedce4e.jpg</url>
      <title>DEV Community: Alex Silaev</title>
      <link>https://dev.to/chromealex</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chromealex"/>
    <language>en</language>
    <item>
      <title>Ten Years of Rollback Netcode in a Cross-Platform RTS, Without Fixed-Point</title>
      <dc:creator>Alex Silaev</dc:creator>
      <pubDate>Sat, 31 Oct 2026 20:26:02 +0000</pubDate>
      <link>https://dev.to/chromealex/ten-years-of-rollback-netcode-in-a-cross-platform-rts-without-fixed-point-2c71</link>
      <guid>https://dev.to/chromealex/ten-years-of-rollback-netcode-in-a-cross-platform-rts-without-fixed-point-2c71</guid>
      <description>&lt;p&gt;Mushroom Wars 2 is a real-time strategy game with thousands of units on screen. It has been live for about ten years, on Switch, PlayStation, Xbox, PC, iOS and Android, and at various points all of those platforms played against each other on the same servers. The netcode is deterministic lockstep with rollback. The simulation runs on plain &lt;code&gt;float&lt;/code&gt;. There is no fixed-point math in it.&lt;/p&gt;

&lt;p&gt;That last part usually starts an argument, so here is how the whole thing works, what actually breaks determinism in practice, and the one bug that taught me the most.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: thin server, fat clients
&lt;/h2&gt;

&lt;p&gt;There are a few classic multiplayer architectures: the server simulates and clients render, one client is the master, or every client simulates the same thing. RTS games and fighting games use the last one. Every client runs the full simulation, and only player inputs travel over the network. The server doesn't run game logic at all. It relays packets, and does a few small things like deciding the winner.&lt;/p&gt;

&lt;p&gt;That makes the server absurdly cheap. In this setup a $50 server holds around 10,000 connected players. Traffic doesn't depend on the number of units either, which is the only reason thousands of units are possible at all.&lt;/p&gt;

&lt;p&gt;Logic runs in fixed ticks. We use 33 ms, so 30 ticks per second. Classic PC RTS games used much longer network turns, around 200 ms in Age of Empires. Rendering and animation run at whatever frame rate the device can do; only the logic is tied to ticks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why rollback, and why StarCraft doesn't need it
&lt;/h2&gt;

&lt;p&gt;Pure lockstep waits: a client doesn't compute tick N until it has every player's input for tick N. On PC that's fine. PC RTS games simply show "waiting for player" when someone lags, and the simulation only ever moves forward.&lt;/p&gt;

&lt;p&gt;On mobile that doesn't work. Pings are high and jumpy, matches last three to five minutes, and nobody wants to stare at a waiting screen. So we do what fighting games do.&lt;/p&gt;

&lt;p&gt;A client applies its own command immediately, so the game feels offline. The packet goes to the server, which forwards it to everyone else. When another client receives "player 1 did X at tick 1" while it is already at tick 5, it restores a saved snapshot from before tick 1, applies the input, and re-simulates ticks 1 to 5. Visual jumps are smoothed by interpolation, or not smoothed at all if units are slow enough that nobody notices.&lt;/p&gt;

&lt;p&gt;Snapshots are taken every 10 to 20 ticks. Fighting games snapshot every tick, because they roll back two or three ticks constantly. We roll back further but less often.&lt;/p&gt;

&lt;p&gt;Here is the skeleton. The state of the world is a set of flat arrays of structs, copied wholesale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RollbackSimulation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;SnapshotInterval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;HistoryTicks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 20 seconds at 30 ticks per second&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="n"&gt;WorldState&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WorldState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;snapshots&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WorldState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;SortedDictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PlayerInput&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;inputs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;SortedDictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PlayerInput&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;dirtyFromTick&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxValue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;AddInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PlayerInput&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;HistoryTicks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// too old: this is already a desync&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PlayerInput&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
        &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PlayerInput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Comparer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// order within a tick: by playerId, then by seq&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dirtyFromTick&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dirtyFromTick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;targetTick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dirtyFromTick&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;restoreTick&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dirtyFromTick&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dirtyFromTick&lt;/span&gt; &lt;span class="p"&gt;%&lt;/span&gt; &lt;span class="n"&gt;SnapshotInterval&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CopyFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;snapshots&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;restoreTick&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;restoreTick&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dirtyFromTick&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxValue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;targetTick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt; &lt;span class="p"&gt;%&lt;/span&gt; &lt;span class="n"&gt;SnapshotInterval&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// deterministic logic step&lt;/span&gt;
            &lt;span class="p"&gt;++&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;SaveSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;snapshots&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;snapshots&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;WorldState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CopyFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;snapshots&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;HistoryTicks&lt;/span&gt;&lt;span class="p"&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;Two details in there matter more than they look.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Sort&lt;/code&gt; inside a tick. If two players send commands for the same tick, every client must apply them in the same order. Packet arrival order is different on every client, so it can't be used. Sort by player id, then by sequence number.&lt;/p&gt;

&lt;p&gt;The cost of &lt;code&gt;CopyFrom&lt;/code&gt;. Re-simulating 10 to 20 ticks inside one frame is routine, so copying the world has to be close to a &lt;code&gt;memcpy&lt;/code&gt;. If a snapshot means walking a thousand objects and allocating, rollback will kill any phone. On our weakest supported devices a logic tick takes about 6 ms out of the 33 ms budget, which leaves room to catch up two or three ticks per frame after a spike.&lt;/p&gt;

&lt;p&gt;Free bonus: replays cost nothing. A match is just its inputs. You are always replaying a recording; it just happens to arrive over the network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who wins when two players do the same thing
&lt;/h2&gt;

&lt;p&gt;Say there's one free spot left to build on, and two players click it 33 ms apart. Each one sees their own building first. Then player 2 receives player 1's packet, rolls back to tick 1, applies "player 1 builds here", reaches its own command at tick 2, and the command is now invalid. It stays in the history, it just doesn't do anything. Player 1 does the mirror image: rolls back, keeps their building, skips player 2's command.&lt;/p&gt;

&lt;p&gt;Packet arrival order doesn't matter, only the ticks written in the packets. Once all packets have arrived, every client has exactly the same world.&lt;/p&gt;

&lt;h2&gt;
  
  
  Floats are deterministic. Mostly.
&lt;/h2&gt;

&lt;p&gt;Here's the part people object to. IEEE 754 defines rounding for basic operations, so the same sequence of additions, multiplications and divisions produces the same bits on compliant hardware. If 10 / 100 comes out as 0.0999999 instead of 0.1, that's fine. What matters is that every client gets the same 0.0999999.&lt;/p&gt;

&lt;p&gt;In practice, divergence comes from three places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The compiler.&lt;/strong&gt; Clients built by the same toolchain with the same settings agree. Build two clients differently and they can diverge. Fused multiply-add is the classic culprit: &lt;code&gt;a * b + c&lt;/code&gt; with one rounding instead of two gives different low bits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Math libraries.&lt;/strong&gt; &lt;code&gt;sin&lt;/code&gt;, &lt;code&gt;cos&lt;/code&gt;, &lt;code&gt;pow&lt;/code&gt; and friends are implemented differently on different platforms. Don't call engine or OS transcendental functions in simulation code. Write your own from basic operations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small tail of weird hardware.&lt;/strong&gt; We saw divergence on roughly 1% of some Android devices from a few vendors. There are tens of thousands of device models out there, and you can't fix all of them. You can put those players in rooms with each other.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The real modern problem is vectorizing compilers. Unity's Burst, for example, may fuse operations into FMA, reorder them in fast-math modes, or use approximate instructions like &lt;code&gt;rsqrt&lt;/code&gt; depending on the target CPU. As far as I know, its deterministic float mode was announced but never actually shipped. If you use Burst for simulation, you will probably end up needing something other than float.&lt;/p&gt;

&lt;p&gt;On a recent project we went the textbook route and built fixed-point math so everything could go through Burst. Then we took it out again, and performance went up by about 40%. Fixed-point isn't free: every multiply needs a widening and a shift, and &lt;code&gt;Sqrt&lt;/code&gt;, &lt;code&gt;Sin&lt;/code&gt; and &lt;code&gt;Atan2&lt;/code&gt; become hand-written tables or iterations that eat exactly the time you were trying to save.&lt;/p&gt;

&lt;p&gt;What I'd recommend today is neither general fixed-point nor float, but plain integers with a unit chosen per quantity. You know the ranges of your own data. If speed never exceeds 100 m/s and a tick never exceeds a second, millimeters and milliseconds are enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;Movement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;positionMm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// position in millimeters&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;speedMmPerSec&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// at most 100,000 (100 m/s)&lt;/span&gt;

    &lt;span class="c1"&gt;// deltaMs is at most 1000&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Tick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;deltaMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 100,000 × 1000 = 10^8, fits in int&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;positionMm&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speedMmPerSec&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;deltaMs&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;1000&lt;/span&gt;&lt;span class="p"&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;Integer math is identical on every CPU and costs nothing extra. Division truncates, which can lose precision for very slow objects, but it truncates the same way everywhere, so determinism is safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually causes desyncs
&lt;/h2&gt;

&lt;p&gt;In ten years, floats were never our main problem. These were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;System.Random&lt;/code&gt; or &lt;code&gt;UnityEngine.Random&lt;/code&gt; in logic. Randomness must be part of the world state, seeded, and rolled back with it.&lt;/li&gt;
&lt;li&gt;Iterating a &lt;code&gt;Dictionary&lt;/code&gt; or &lt;code&gt;HashSet&lt;/code&gt;. Order is not guaranteed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GetHashCode()&lt;/code&gt; on strings and objects. In .NET Core it is randomized per process.&lt;/li&gt;
&lt;li&gt;The engine's physics. PhysX is not deterministic across machines.&lt;/li&gt;
&lt;li&gt;Unstable sorts with equal keys. Always add a secondary key like the entity id.&lt;/li&gt;
&lt;li&gt;Anything that touches &lt;code&gt;deltaTime&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one more category that no rule catches.&lt;/p&gt;

&lt;h2&gt;
  
  
  The forgotten field
&lt;/h2&gt;

&lt;p&gt;Our snapshots used to be taken by &lt;code&gt;CopyFrom&lt;/code&gt; methods written by hand, one per state class. A unit had about a hundred fields. Someone added a new one and forgot to copy it.&lt;/p&gt;

&lt;p&gt;Nothing broke. Without a rollback, the field lives its normal life and the game is correct. It only breaks when a rollback lands exactly on a tick where that field matters: one client restores the snapshot and keeps the stale value from the discarded prediction, another client has the correct one. In our case, a building got captured on one client and stayed neutral on the other. Reproducing that by hand is close to impossible.&lt;/p&gt;

&lt;p&gt;We caught it with hashes. Clients periodically hash the world state for a tick that is already confirmed (all inputs received) and send it to the server. The share of matches with mismatched hashes is a permanent metric for us. There is always some background noise from the hardware tail above, but one day the rate went up. That meant a new source of divergence, somewhere in code that touches simulation state.&lt;/p&gt;

&lt;p&gt;The fix came in two steps. First, a reflection test that finds every type with a &lt;code&gt;CopyFrom&lt;/code&gt;, fills an instance with random values, copies it, and compares every field, including private ones in base classes. It catches a forgotten field on the first run instead of a hundred matches later. Second, all blittable fields of a unit moved into one struct, so the copy became a single assignment. A new field can't be forgotten anymore, because the compiler copies it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hashes, cheaters, and the 1v1 problem
&lt;/h2&gt;

&lt;p&gt;The same hashes handle cheating. If three clients report hash A and one reports hash B, the odd one out is told to roll back further and re-simulate. A transient glitch fixes itself. A modified client keeps producing B and eventually gets removed from the room. Nobody else notices anything.&lt;/p&gt;

&lt;p&gt;In one-on-one games you can't tell who is right with two votes. For that, the server can run a headless client fed with all the match inputs. It renders nothing and re-simulates the whole match almost instantly; whoever it agrees with wins. It's also the best debugging tool we have for real desyncs.&lt;/p&gt;

&lt;p&gt;Two practical notes on the hash itself. Don't use &lt;code&gt;GetHashCode()&lt;/code&gt;. Walk the raw bytes of the state in a fixed order with something simple like FNV-1a, and zero your structs on creation, because padding bytes can contain garbage and break the hash for identical data. And only hash ticks for which all inputs are confirmed, or you'll chase "desyncs" that disappear after the next rollback.&lt;/p&gt;

&lt;h2&gt;
  
  
  A self-check worth stealing
&lt;/h2&gt;

&lt;p&gt;The cheapest determinism test we have doesn't need a network. In the editor, a debug mode rolls back one tick on every tick, re-simulates it, and compares the hash. Any state that isn't fully captured by snapshots, any hidden dependency on time or order, shows up immediately, long before a real match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Rollback lets a lockstep game feel offline on bad mobile connections, at the cost of re-simulating ticks. Make snapshots a memory copy, or don't do it.&lt;/li&gt;
&lt;li&gt;Floats can be deterministic in production across consoles, PC and phones, if you control the compiler and stay away from platform math libraries and vectorizing fast-math. Fixed-point is a real cost, not a free safety net.&lt;/li&gt;
&lt;li&gt;Most desyncs come from randomness, iteration order, hashing and hand-written copy code, not from floating point.&lt;/li&gt;
&lt;li&gt;Hash confirmed ticks, track the desync rate as a metric, and keep a headless client on the server for disputes.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I'm Alex Silaev, CTO at Zillion Whales, the studio behind Mushroom Wars 2. This article is based on the networking chapter of my book on Unity performance, memory and architecture, available in English on &lt;a href="https://www.patreon.com/chromealex/posts/book-everything-173962820" rel="noopener noreferrer"&gt;Patreon&lt;/a&gt; and &lt;a href="https://boosty.to/chromealex/posts/7d456477-1b06-460d-8cc7-5f8defad2dcb" rel="noopener noreferrer"&gt;Boosty&lt;/a&gt;. The determinism ideas here also drive &lt;a href="https://github.com/chromealex/ME.BECS" rel="noopener noreferrer"&gt;ME.BECS&lt;/a&gt;, my open-source deterministic ECS framework for Unity.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>networking</category>
      <category>csharp</category>
      <category>unity3d</category>
    </item>
    <item>
      <title>One Forgotten Field Out of a Hundred: Hunting a Desync in Mushroom Wars 2</title>
      <dc:creator>Alex Silaev</dc:creator>
      <pubDate>Sat, 31 Oct 2026 14:06:02 +0000</pubDate>
      <link>https://dev.to/chromealex/one-forgotten-field-out-of-a-hundred-hunting-a-desync-in-mushroom-wars-2-427</link>
      <guid>https://dev.to/chromealex/one-forgotten-field-out-of-a-hundred-hunting-a-desync-in-mushroom-wars-2-427</guid>
      <description>&lt;p&gt;Some bugs are invisible to the compiler, slip past gameplay tests, and can't be reproduced by hand. In a networked game with rollback, the classic example is a field someone forgot to copy. Here's how we found one in Mushroom Wars 2, and how we made sure this kind of bug can't happen again.&lt;/p&gt;

&lt;h2&gt;
  
  
  How our rollback works
&lt;/h2&gt;

&lt;p&gt;Mushroom Wars 2 is an RTS where every player runs the same deterministic simulation and only inputs travel over the network. To avoid waiting for other players' commands, the client simulates ahead using a prediction. Before predicting, it saves a snapshot of the world. When the other players' inputs arrive, the client restores the snapshot and re-simulates those ticks with the correct data.&lt;/p&gt;

&lt;p&gt;Snapshots were taken with a &lt;code&gt;CopyFrom&lt;/code&gt; method on every state class, and each method listed its fields by hand. A unit has around a hundred of them. One day someone added a new field and forgot to add it to the unit's &lt;code&gt;CopyFrom&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bug is nearly invisible
&lt;/h2&gt;

&lt;p&gt;As long as no rollback happens, the field lives its own life and the game behaves correctly. It only breaks when a rollback lands on a moment where that field matters. After the snapshot is restored, one client still holds the value from the discarded prediction, while another holds the correct one. The simulations diverge even though the inputs are identical.&lt;/p&gt;

&lt;p&gt;For us it looked like this: on one client a player captured a building, while on the other client the same building stayed neutral. Reproducing that by hand is almost impossible, because you need a rollback on exactly the tick where the forgotten field changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we found it: hashes
&lt;/h2&gt;

&lt;p&gt;Clients send state hashes to the server, and the share of matches with mismatching hashes is one of our permanent metrics. There is always some background level of desyncs, but at some point the rate went above normal. That was the signal: a new source of divergence had appeared, and it had to be somewhere in code that touches simulation state.&lt;/p&gt;

&lt;p&gt;Without that metric, a rare desync would have looked like a handful of odd player reports that nobody could reproduce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: a test that finds the forgotten field
&lt;/h2&gt;

&lt;p&gt;Before touching the code, I asked an AI model to write a test. It finds every class in the project that has a &lt;code&gt;CopyFrom&lt;/code&gt; method, fills a source object with random values, copies it, and compares every field, private ones included. It's a mechanical task that is easy to verify by eye, which is exactly where AI does well.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CopyFromTests&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;IEnumerable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CopyableTypes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Assembly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetTypes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsClass&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;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAbstract&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CopyFrom"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;TestCaseSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CopyableTypes&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;CopyFromCopiesEveryField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Activator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Activator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;TestData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FillRandom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;42&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CopyFrom"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;Invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BindingFlags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Instance&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;BindingFlags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Public&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;BindingFlags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NonPublic&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetFields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;equal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TestData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DeepEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
            &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;$"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; was not copied"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&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;&lt;code&gt;TestData.FillRandom&lt;/code&gt; and &lt;code&gt;TestData.DeepEquals&lt;/code&gt; are recursive helpers that walk into nested structs, lists and arrays. One subtlety: &lt;code&gt;GetFields&lt;/code&gt; doesn't return private fields declared in base classes, so the real test collects fields by walking up the &lt;code&gt;BaseType&lt;/code&gt; chain.&lt;/p&gt;

&lt;p&gt;A test like this catches a forgotten field on its first run instead of a hundred matches later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: make the mistake impossible
&lt;/h2&gt;

&lt;p&gt;A test is a safety net, but it's better when the mistake simply can't be made. I moved all of the unit's blittable fields into a single struct. Assigning a struct copies it in full, so &lt;code&gt;CopyFrom&lt;/code&gt; shrank to one line for the data plus manual copying of a few reference fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;UnitData&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;health&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;captureProgress&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...and about a hundred more blittable fields&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;UnitData&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;CopyFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Unit&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&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;The field names here are illustrative. The point is that a hundred lines of assignments became one. A new field added to the struct is copied automatically, so there's no way to forget it anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;This class of bugs is gone. The compiler copies the fields inside the struct, and the test guards the few reference fields that are still copied by hand. It runs on every change and immediately names the class and the field that was missed.&lt;/p&gt;

&lt;p&gt;What I took away from this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Listing fields by hand in copy, compare or serialization code is a time bomb.&lt;/strong&gt; The more fields there are, the more certain it is that one of them will eventually be forgotten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep simulation state in blittable structs.&lt;/strong&gt; Copying, hashing and serialization then become operations on a single block of memory. My ECS framework &lt;a href="https://github.com/chromealex/ME.BECS" rel="noopener noreferrer"&gt;ME.BECS&lt;/a&gt; is built on this idea: the whole world state there is one block of memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare state hashes between clients every few ticks.&lt;/strong&gt; Then you see a desync on the tick it happened, not when a player complains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests for mechanical invariants like "the copy equals the original" are a great job for AI.&lt;/strong&gt; They're easy to review and pay off a lot.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I'm Alex Silaev, CTO at Zillion Whales, the studio behind Mushroom Wars 2, and I've been building games in Unity for more than 15 years. This is one of the production stories from my book on Unity performance, memory and architecture (currently in Russian). More of my work: &lt;a href="https://github.com/chromealex" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, &lt;a href="https://chromealex.com" rel="noopener noreferrer"&gt;chromealex.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>unity3d</category>
      <category>csharp</category>
      <category>gamedev</category>
      <category>multiplayer</category>
    </item>
    <item>
      <title>How the Bot in Mushroom Wars 2 Works: Forecasting, Openings and Honest Difficulty</title>
      <dc:creator>Alex Silaev</dc:creator>
      <pubDate>Sat, 31 Oct 2026 14:05:56 +0000</pubDate>
      <link>https://dev.to/chromealex/how-the-bot-in-mushroom-wars-2-works-forecasting-openings-and-honest-difficulty-154m</link>
      <guid>https://dev.to/chromealex/how-the-bot-in-mushroom-wars-2-works-forecasting-openings-and-honest-difficulty-154m</guid>
      <description>&lt;p&gt;Writing a bot that always wins is not the hard part of making an RTS. Writing a bot that's fun to play against is much harder. I've lost count of how many bots I've written. Here's what the bot in Mushroom Wars 2 is built on, what we had to teach it by hand, and how we tune difficulty so players don't feel the bot is throwing the game.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core function: "how many will there be in T seconds"
&lt;/h2&gt;

&lt;p&gt;Our bots are built on weights, an approach in the spirit of Utility AI. But weights alone don't get you far if the bot only looks at the current state.&lt;/p&gt;

&lt;p&gt;An example. The map has enemy buildings with 50, 10 and 70 units. The bot has 100 units in its building and has to decide where to send them, and whether to send them at all. For simplicity, assume captures trade units one for one. The distances to the targets are 30, 60 and 80.&lt;/p&gt;

&lt;p&gt;A human looks at this and thinks: I have 100, they have 50, easy. But every building produces units. So the most important method, and one of the first worth writing, is "how many will there be after some time." It's a universal tool, not just for units in buildings. Say an enemy regenerates HP and is running away from you. From your speed and theirs, you can work out when you'll catch them and how much HP they'll have regained by then. Maybe the chase isn't worth it. Or the other way round: with your damage output, maybe you can finish them off in time.&lt;/p&gt;

&lt;p&gt;Most of the bot's decisions rest on this function. Plug it into Utility AI scoring and the bot immediately looks smarter, because it starts predicting: "I'm not going there."&lt;/p&gt;

&lt;h2&gt;
  
  
  How the bot picks a target
&lt;/h2&gt;

&lt;p&gt;Back to the map. By the time our units cover 30 meters, the target building will have, say, 60 units. We can still take that: 100 against 60 leaves 40. Great? No.&lt;/p&gt;

&lt;p&gt;As soon as we send units, the enemy sees where we're heading and can reinforce the target from nearby buildings. So there's a radius within which we account for the support the target can receive. To those 60 we add the 10 from one neighbor and the 70 from another, but not in full: we account for distance and how many of them can arrive in time. Now we're at, say, 110. Meanwhile, about 10 seconds pass, during which our own building produces another 20.&lt;/p&gt;

&lt;p&gt;We add it all up and check whether we win. It can turn out that attacking here makes no sense, and it's better to go for buildings that are farther away but have no nearby point that could wipe us out after our attack. The bot picks what to defend and how to carry out attacks the same way.&lt;/p&gt;

&lt;p&gt;The forecasting function is called hundreds of times per decision: for every target, every possible reinforcement, every attack source. So it should be pure and cheap. Here's a sketch for the "production plus reinforcements within a radius" mechanic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;Building&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;float2&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;units&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;productionPerSec&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// limit after which production stops&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Forecast&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nf"&gt;UnitsAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Building&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;units&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;productionPerSec&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;units&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="c1"&gt;// How many defenders the target will have when the attack arrives.&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nf"&gt;DefendersOnArrival&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;NativeArray&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Building&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                           &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;arrivalTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;unitSpeed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                           &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;supportRadius&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;supportShare&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tgt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;UnitsAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;arrivalTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;++&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dist&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;supportRadius&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="c1"&gt;// The enemy only reacts once it sees our units leave,&lt;/span&gt;
            &lt;span class="c1"&gt;// and only helps if it arrives before we do.&lt;/span&gt;
            &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;travel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;unitSpeed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;travel&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;arrivalTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;UnitsAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;arrivalTime&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;travel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;supportShare&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&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;&lt;code&gt;supportShare&lt;/code&gt; is your assumption about what fraction of a garrison the opponent will actually send to defend. Against a human, 0.5 to 0.7 is more reasonable than 1, and at high difficulty you can learn from replays how often players really reinforce. The resulting value is normalized and fed into the target's utility score along with the building's value and the risk of a counterattack.&lt;/p&gt;

&lt;p&gt;Some games with similar mechanics have bots that reason like this: I have 100, there are 10 next door, so attack. And the bot just keeps feeding units away. In our game, many factors affect whether an attack succeeds. For example, a successful attack grants a kind of morale bonus that affects later attacks and defense. All of that has to be taken into account too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Openings: what the bot couldn't learn on its own
&lt;/h2&gt;

&lt;p&gt;We taught the bot to attack and defend, and it all worked. But it couldn't play the start of a match as efficiently as a human. At the beginning the map is empty, surrounded by neutral buildings that don't defend themselves; you just capture them. The bot captured everything, but much more slowly than a top player who has studied every map. And the start of a match shapes everything that follows.&lt;/p&gt;

&lt;p&gt;So we recorded openings for it. We literally wrote down the initial moves in several variants, and at the start of a match the bot picks one at random. It develops like a player, and once it holds half the map it switches to its regular logic.&lt;/p&gt;

&lt;p&gt;There's nothing wrong with openings, and they're useful in many genres, shooters included. You spawn at a specific location and almost always do one of a few standard things. After that comes micro, which any of the classic algorithms can handle. The key is to keep several variants and choose randomly, otherwise the bot becomes predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neural networks: two attempts
&lt;/h2&gt;

&lt;p&gt;The first story isn't mine; a developer I know told it to me. In chess-like games, bots have a problem: they're either too strong or too dumb. Search many plies ahead and the bot is unbeatable. Search only a few, and a strong player calculates three moves ahead and humiliates it. That's exactly what happened to them: a good chess player showed up and tore the bot apart. The developer took a simple perceptron, three layers if I remember right, and fed it a few dozen games from a database. The network's output was a heatmap over the board's squares, and those coefficients were added to the evaluation in the main search algorithm. Even with shallow search, the bot started changing its strategy. It worked, but didn't ship, because of the computational cost of the search itself rather than the network.&lt;/p&gt;

&lt;p&gt;My own attempt was in Mushroom Wars. I tried to teach the bot to use skills optimally, and we have a lot of them: speeding up units, burning units in an area, quickly moving units from one building to another, and so on. The bot uses skills at a basic level, and the rules are simple: for example, burning makes sense if the enemy has more units within a radius than some threshold. But when to use them was a big problem. We tried training a network on replays, weren't happy with the results, and dropped it. Most likely we just didn't train enough: there are a million parameters, and it all needs careful tuning.&lt;/p&gt;

&lt;p&gt;Between a hard-coded rule and a neural network there are middle-ground options that usually get results faster:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Utility AI with forecasting.&lt;/strong&gt; Evaluate not "how many enemies are in the radius right now" but "how much value will I destroy or save," using the same "what happens in T" function, and compare that with the cost of the cooldown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mini-simulation.&lt;/strong&gt; If the game rules are deterministic (and in an RTS with lockstep networking they have to be anyway), you can run the simulation a few seconds ahead with and without the ability and compare the outcomes. It's expensive, so candidates are first filtered by simple rules and only the 2 or 3 best are simulated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For chess-like bots,&lt;/strong&gt; instead of limiting search depth, it works well to pick not the best move but a random one among moves whose evaluation is within a set margin of the best. That margin is your difficulty knob.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Making the bot more human
&lt;/h2&gt;

&lt;p&gt;People often ask whether we try to make the bot's behavior more human. Especially in games where everything is easy to compute, like billiards, where the bot wrecks anyone the moment it gets a turn.&lt;/p&gt;

&lt;p&gt;Yes, we do. We started by teaching the bot to evaluate mid-match whether it's worth playing to the end: how much territory it holds, how much the enemy has, and so on. The bot has ways to end a match, for example building towers and then surrendering.&lt;/p&gt;

&lt;p&gt;In general, every bot probably starts the same way: you write the strongest bot you can. When it becomes uncomfortable to play against, you start handing it difficulty levels, which means making it weaker. The simplest way to weaken a bot in an RTS is a "thinking" delay. The bot starts to fumble: it sent units, should send support, and it's already too late. Things stop adding up, and the difficulty drops.&lt;/p&gt;

&lt;p&gt;A couple of details that make such a delay feel natural:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The delay shouldn't be a constant.&lt;/strong&gt; Draw it from a distribution that resembles human reaction time, for example log-normal with a mean of 300 to 600 ms on easy levels. Apply it not only to reactions but also to how often the bot reconsiders its decisions. Then the bot makes mistakes "like a human": not the same way every time, but occasionally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Degrade perception, not decisions.&lt;/strong&gt; A bot that sees worse or notices threats later looks natural. A bot that deliberately picks a worse option reads to players as "the bot is letting me win."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The easiest way to make a bot harder is to give it more at the start or tweak its coefficients: 10% faster production, faster units, more units at the start. But we only do that in the single-player campaign, where players are prepared for the bot to hold half the map from the beginning.&lt;/p&gt;

&lt;p&gt;In multiplayer, early on we have bots that teach the player how to play. We gradually make them harder and then release the player to real people. Those bots play fair, one on one, with no handicaps, and the openings exist precisely so they behave like players.&lt;/p&gt;

&lt;h2&gt;
  
  
  Synchronized attacks
&lt;/h2&gt;

&lt;p&gt;Here's what we did at the highest difficulty level. Say the bot holds three buildings and wants to attack a fourth. It doesn't send everyone at once. It first sends units from the farthest building; when they reach a certain line, it sends from the next one, then from the third. The whole crowd hits the target at the same moment. It's effective, it looks great, and players love doing it themselves.&lt;/p&gt;

&lt;p&gt;Technically it's just planning backwards from the end: compute each group's travel time &lt;code&gt;t_i = dist_i / speed&lt;/code&gt;, take the maximum &lt;code&gt;T&lt;/code&gt;, and send group &lt;code&gt;i&lt;/code&gt; with a delay of &lt;code&gt;T - t_i&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Whatever you build on, whether FSM, Behaviour Tree, Utility AI or GOAP, two things work on top of it. A forecasting function, "what will happen in T," that makes the bot smarter. And an honest answer to the question of whether it's fun to play against.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Alex Silaev, CTO at Zillion Whales, the studio behind Mushroom Wars 2, and I've been building games in Unity for more than 15 years. This article is adapted from the game AI chapter of my book on Unity performance, memory and architecture, available in English on &lt;a href="https://boosty.to/chromealex" rel="noopener noreferrer"&gt;Boosty&lt;/a&gt;. More of my work: &lt;a href="https://github.com/chromealex" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, &lt;a href="https://chromealex.com" rel="noopener noreferrer"&gt;chromealex.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>ai</category>
      <category>unity3d</category>
      <category>csharp</category>
    </item>
    <item>
      <title>220,000 Units Instead of 4,000 and Other Production Stories from Mushroom Wars 2</title>
      <dc:creator>Alex Silaev</dc:creator>
      <pubDate>Sat, 31 Oct 2026 14:05:46 +0000</pubDate>
      <link>https://dev.to/chromealex/220000-units-instead-of-4000-and-other-production-stories-from-mushroom-wars-2-8o6</link>
      <guid>https://dev.to/chromealex/220000-units-instead-of-4000-and-other-production-stories-from-mushroom-wars-2-8o6</guid>
      <description>&lt;p&gt;Almost every story below started with code that worked until it didn't. These are real cases from Mushroom Wars 2, an RTS where thousands of units run around on a phone screen at once, and which we shipped on PS4, Xbox One, Switch, iOS, Android and PC. For each story: what broke, how we found it, what we did, and what the numbers were.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Garbage collection in the middle of a battle
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The problem.&lt;/strong&gt; Mushroom Wars 2 is a fast-paced strategy game, and any hitch ruins the feel. Garbage collection mid-battle caused noticeable stalls, especially on consoles (PS4, Switch).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How we found it.&lt;/strong&gt; At first the freezes were visible with the naked eye. The Unity profiler and the console's native profiler showed that the stalls lined up with &lt;code&gt;GC.Collect&lt;/code&gt;. On those platforms a collection could take up to a couple of seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we did.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Moved all gameplay code to object pools: not a single managed object is created during a battle.&lt;/li&gt;
&lt;li&gt;Disabled the GC before a battle, then re-enabled it afterwards and collected manually on the results screen, where the pause goes unnoticed.&lt;/li&gt;
&lt;li&gt;Back then this required hacks; today there's the built-in &lt;code&gt;GarbageCollector.GCMode&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The numbers.&lt;/strong&gt; Before: the heap grew noticeably during battles, because there were allocations mid-gameplay, and every one of them had to be found and removed. Collections took up to ~2 s on PS4 and Switch. After: the heap doesn't grow during a battle, and there are zero collections per battle.&lt;/p&gt;

&lt;p&gt;The catch: a single forgotten allocation in battle starts growing the heap again, and with the GC disabled that's no longer a stall but memory running out.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The premature optimization that saved the release
&lt;/h2&gt;

&lt;p&gt;When we wrote the unit renderer in 2015, Unity couldn't draw much in large numbers, so we optimized and micro-optimized everything we saw. The renderer needs to know the upper bound on the number of units in advance to allocate its buffers. We computed it as the sum of units in all buildings on the map, multiplied by the number of players (in theory, each one can use an ability that duplicates units). If we missed something, the extra units just don't get drawn, and that's fine: with a thousand units on screen you can't tell who's who anyway, let alone with four thousand.&lt;/p&gt;

&lt;p&gt;That formula lived for seven years. Then it backfired.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. 220,000 units instead of 4,000
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The problem.&lt;/strong&gt; A low-end device crashed while loading one specific map. Every other map worked fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How we found it.&lt;/strong&gt; The behavior was typical of running out of memory, and the logs confirmed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cause.&lt;/strong&gt; The same formula from 2015. A level designer had placed 5,000 units in buildings "off the map" so they could be released by a trigger. As a result, instead of a maximum of ~4,000 units (and render buffers for 20,000), the game started reserving and processing ~220,000.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why nobody noticed earlier.&lt;/strong&gt; We render units with particles, and the extra particles didn't give themselves away. The map shipped like that for about three months: normal devices had enough memory, and only the low-end one crashed. In the end, we found the cause almost by accident while refactoring the unit renderer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; It turned out the particle system was being given a capacity based on that formula, multiplied by the number of layers on top. And it was drawing every particle in the buffer, not just the ones belonging to live units. We fixed the capacity calculation and started drawing only the particles actually in use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson.&lt;/strong&gt; Code nobody has touched in seven years still enforces limits that everyone has forgotten about. Computed limits need to be checked against real content data. A build-time map validator that reports "this map needs a buffer for N units" would have caught the problem the day it appeared.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Unit paths that cost almost nothing
&lt;/h2&gt;

&lt;p&gt;Units in Mushroom Wars 2 always move from building to building, and the map is static. That means every path can be built ahead of time in the editor, with no pathfinding at runtime at all. Paths are built in three stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A* on a grid.&lt;/strong&gt; In the editor the whole field is a grid, and A* easily finds a path from building to building around obstacles. But a grid path is jagged and hugs obstacles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A chain of colliders.&lt;/strong&gt; Along that path we create a chain of box colliders connected by joints. Each collider is the size of a unit in the game. There are up to eight paths between buildings, so up to eight chains at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A physics simulation.&lt;/strong&gt; We run physics 50 frames ahead. The chains push off each other and off obstacles, tighten and smooth out, like ropes. The result is clean paths through narrow passages, neat curves around buildings, and roads that don't overlap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We save the collider positions as spline control points. At the start of a battle, a Catmull-Rom spline is built from them, and units simply move along the spline. A squad marches in neat rows and keeps formation, which looks good and has a big effect on how the game feels.&lt;/p&gt;

&lt;p&gt;A battle can have up to 4,000 units, and building paths for them at runtime would be expensive; this way it's almost free. The limitation is obvious: the approach only works because the map and the set of routes are known in advance. A dynamic world needs hierarchical pathfinding with flow fields per chunk.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. How a 2D game got real shadows
&lt;/h2&gt;

&lt;p&gt;We built Mushroom Wars 2 for Steam first. Our experience and the hardware would have allowed 3D, but we deliberately chose 2D: any "beautiful 3D" looks dated within a few years, while good hand-drawn 2D ages slowly.&lt;/p&gt;

&lt;p&gt;We drew the buildings and units in pixel-perfect 2D, but we wanted real shadows. The solution turned out to be fun. Around the outline of each building we made a low-poly mesh slightly larger than the building itself and placed the 2D sprite inside it, but didn't render the mesh. The mesh only took part in shadow calculations, casting and receiving shadows, while only the sprite was visible on screen. The result was pixel-perfect rendering with real-time shadows.&lt;/p&gt;

&lt;p&gt;Later we dropped mesh shadows in favor of sprites and render textures, because we needed to support low-end devices. But the story stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Players losing their accounts on iOS
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The problem.&lt;/strong&gt; Players who hadn't linked a social account complained about losing progress, mostly on iOS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it worked.&lt;/strong&gt; We wanted people to be able to play without signing in. To do that, the client sent the server a unique string, and we used &lt;code&gt;SystemInfo.deviceUniqueIdentifier&lt;/code&gt;. Once upon a time that was a stable hardware identifier; now it can change. On iOS it isn't the advertising IDFA (which requires ATT permission) but identifierForVendor, and it changes if the user deletes all of a developer's apps from the device. So that an OS-side ID change wouldn't affect the player, we stored the first value we received in &lt;code&gt;PlayerPrefs&lt;/code&gt;. Of course we offered to link a social account to keep progress safe, but that's like asking people to read the readme.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How we found it.&lt;/strong&gt; The complaints all came down to one scenario: the player deleted the game and reinstalled it. &lt;code&gt;PlayerPrefs&lt;/code&gt; are deleted along with the app, and if there were no other apps of ours on the device, identifierForVendor changed too. New identifier, new account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we did.&lt;/strong&gt; We started storing the key in the Keychain, which survives reinstalls. The complaints stopped. The Keychain has its own pitfall: if you transfer the app to a different developer account, you lose access to the data stored there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cheat sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Story&lt;/th&gt;
&lt;th&gt;Lesson&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GC mid-battle&lt;/td&gt;
&lt;td&gt;Pool all gameplay objects, disable the GC during battles with &lt;code&gt;GarbageCollector.GCMode&lt;/code&gt;, collect on the results screen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Premature optimization&lt;/td&gt;
&lt;td&gt;An upper bound for the renderer, set early, saved the release, but its assumptions live on for years&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;220,000 units&lt;/td&gt;
&lt;td&gt;Check computed limits against real maps with a build-time validator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unit paths&lt;/td&gt;
&lt;td&gt;If the map is static, build paths in the editor and leave only the spline for runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shadows in 2D&lt;/td&gt;
&lt;td&gt;An invisible low-poly mesh around the sprite only casts and receives shadows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accounts on iOS&lt;/td&gt;
&lt;td&gt;Store the guest account key in the Keychain, not in &lt;code&gt;PlayerPrefs&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;&lt;em&gt;I'm Alex Silaev, CTO at Zillion Whales, the studio behind Mushroom Wars 2, and I've been building games in Unity for more than 15 years. These stories come from the production chapter of my book on Unity performance, memory and architecture, available in English on &lt;a href="https://www.patreon.com/chromealex/posts/book-everything-173962820" rel="noopener noreferrer"&gt;Patreon&lt;/a&gt; and &lt;a href="https://boosty.to/chromealex/posts/7d456477-1b06-460d-8cc7-5f8defad2dcb" rel="noopener noreferrer"&gt;Boosty&lt;/a&gt;. More of my work: &lt;a href="https://github.com/chromealex" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, &lt;a href="https://chromealex.com" rel="noopener noreferrer"&gt;chromealex.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>unity3d</category>
      <category>gamedev</category>
      <category>performance</category>
      <category>csharp</category>
    </item>
  </channel>
</rss>
