<?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: Erol</title>
    <description>The latest articles on DEV Community by Erol (@erol4).</description>
    <link>https://dev.to/erol4</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%2F351309%2Fbad8607c-bdc6-49c9-9cca-b655fffeb0a8.jpeg</url>
      <title>DEV Community: Erol</title>
      <link>https://dev.to/erol4</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/erol4"/>
    <language>en</language>
    <item>
      <title>Reverse engineering the Agilent ChemStation .ch and .uv file formats</title>
      <dc:creator>Erol</dc:creator>
      <pubDate>Fri, 28 Aug 2026 13:18:24 +0000</pubDate>
      <link>https://dev.to/erol4/reverse-engineering-the-agilent-chemstation-ch-and-uv-file-formats-24kh</link>
      <guid>https://dev.to/erol4/reverse-engineering-the-agilent-chemstation-ch-and-uv-file-formats-24kh</guid>
      <description>&lt;p&gt;Agilent ChemStation writes chromatography data into a &lt;code&gt;.D&lt;/code&gt; directory of binary files. The vendor has never published the layout. Nothing in the files is encrypted and there is no licence check anywhere in the format, so they can be read offline, but you have to work the layout out first.&lt;/p&gt;

&lt;p&gt;What follows is what is in them, from writing a reader and checking it against real files from six instruments.&lt;/p&gt;

&lt;h2&gt;
  
  
  File structure
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;.D&lt;/code&gt; is a directory, not a file. The signal data is in files inside it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.ch&lt;/code&gt;: one detector channel over time. A single column of numbers.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.uv&lt;/code&gt;: diode array data. A full spectrum at every time point.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both start with a number identifying the layout. Read it first and branch on it. Do not branch on the extension, since the same extension covers layouts with almost nothing in common.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Ext&lt;/th&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;Body&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.ch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0x400&lt;/code&gt;, one byte per char&lt;/td&gt;
&lt;td&gt;deltas, big endian&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;130&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.ch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0x1800&lt;/code&gt;, UTF-16&lt;/td&gt;
&lt;td&gt;deltas, big endian&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;179&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.ch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0x1800&lt;/code&gt;, UTF-16&lt;/td&gt;
&lt;td&gt;float64, little endian&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.uv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0x200&lt;/code&gt;, one byte per char&lt;/td&gt;
&lt;td&gt;deltas, little endian&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;131&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.uv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0x1000&lt;/code&gt;, UTF-16&lt;/td&gt;
&lt;td&gt;deltas, or float64. Both occur.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An unrecognised type should be rejected by number, not routed to the nearest matching path. A wrong chromatogram looks plausible on inspection and will get used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delta encoding
&lt;/h2&gt;

&lt;p&gt;Types 30, 130, 31 and 131 store changes rather than values. Each segment begins with a count, followed by that many signed 16-bit values, each added to a running total. A value of &lt;code&gt;-0x8000&lt;/code&gt; is an escape: the next four bytes are a new absolute total.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp0ve1vk3gi6bdzu7oc7g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp0ve1vk3gi6bdzu7oc7g.png" alt="Seven rows from a sample file, showing three deltas and four -0x8000 escapes" width="799" height="446"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInt16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// big endian for .ch&lt;/span&gt;
  &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;2&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="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mh"&gt;0x8000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInt32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// escape: new absolute total&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&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;Cost is 16 bits per point, and 32 only where the signal jumps. The file above uses the escape 22 times across 2,100 points.&lt;/p&gt;

&lt;h2&gt;
  
  
  Byte order
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.ch&lt;/code&gt; stores these values big endian. &lt;code&gt;.uv&lt;/code&gt; stores them little endian.&lt;/p&gt;

&lt;p&gt;The wrong byte order does not throw. The segment walk loses alignment, reaches a byte pair that reads as the escape, and terminates:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh1n9j6x1sc10xp6x7i50.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh1n9j6x1sc10xp6x7i50.png" alt="The same file read two ways: 2,100 points as a chromatogram, and 127 points of noise" width="799" height="526"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The result is hard to distinguish from a run where acquisition stopped early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Undocumented offsets
&lt;/h2&gt;

&lt;p&gt;Four values are not in any published description. The &lt;a href="https://github.com/evanyeyeye/rainbow" rel="noopener noreferrer"&gt;rainbow project&lt;/a&gt; published the notes this work started from, and two of its values do not match files in the wild.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;.uv&lt;/code&gt; scaling factor is at &lt;code&gt;0xC0D&lt;/code&gt;, not &lt;code&gt;0x127C&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;.uv&lt;/code&gt; header ends at &lt;code&gt;0x1000&lt;/code&gt;, so the documented address falls inside the data body:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffiyjhosf2a4nt6jfr2io.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffiyjhosf2a4nt6jfr2io.png" alt="A .uv file drawn to scale, with 0x127C falling past the end of the header" width="800" height="352"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This one can be ruled out without a sample file. The correct value sits at &lt;code&gt;0xC0D&lt;/code&gt;, immediately before the units string that the same document places correctly at &lt;code&gt;0xC15&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Legacy string layout.&lt;/strong&gt; Types 30 and 31 are not a truncated modern header. Strings sit at unrelated offsets and use one byte per character: acquisition date at &lt;code&gt;0xB2&lt;/code&gt;, units at &lt;code&gt;0x244&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Legacy scaling factors.&lt;/strong&gt; &lt;code&gt;0x284&lt;/code&gt; for &lt;code&gt;.ch&lt;/code&gt; type 30, &lt;code&gt;0x13E&lt;/code&gt; for &lt;code&gt;.uv&lt;/code&gt; type 31.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenLab &lt;code&gt;.uv&lt;/code&gt; segments.&lt;/strong&gt; Label 70 with a plain little endian float64 body, rather than the documented label 67 with a delta encoded body. Both occur and both have to be handled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;A parser cannot be tested against the author's own reading of the format, since that reading is what is under test. The check has to be external.&lt;/p&gt;

&lt;p&gt;Every decoded value is compared against rainbow's output across 11 files from six instruments, covering every supported type: 1,487,470 values. All match, at a maximum difference of zero rather than inside a tolerance.&lt;/p&gt;

&lt;p&gt;A second test re-derives each reported offset independently, so a claim about where a field lives holds up without sharing a constant with the parser. Together these catch the usual failure in this kind of work, which is an offset that is correct only for the sample set in hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Known gaps
&lt;/h2&gt;

&lt;p&gt;Type 181 is implemented from the layout it shares with 179 and has never been checked against a real file, because no public sample exists.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.ch&lt;/code&gt; retention times are reconstructed, not read. The header carries a first time, a last time and a point count, and times are an even ramp between them, which is all the format provides. A non-uniform sample rate would be reported wrongly by any reader. Every file examined so far is uniform.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.uv&lt;/code&gt; carries a timestamp per time point, so those are read rather than inferred.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Format reference, every offset with the reasoning: &lt;a href="https://peakwright.com/format" rel="noopener noreferrer"&gt;peakwright.com/format&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Reader and tests, MIT: &lt;a href="https://github.com/Erol444/agilent-chemstation-parser" rel="noopener noreferrer"&gt;github.com/Erol444/agilent-chemstation-parser&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Browser viewer, decodes in the tab and uploads nothing: &lt;a href="https://peakwright.com" rel="noopener noreferrer"&gt;peakwright.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Prior art: &lt;a href="https://github.com/evanyeyeye/rainbow" rel="noopener noreferrer"&gt;rainbow&lt;/a&gt; in Python, and &lt;a href="https://github.com/ethanbass/chromConverter" rel="noopener noreferrer"&gt;chromConverter&lt;/a&gt; in R, which covers more vendor formats than anything else I am aware of.&lt;/p&gt;

</description>
      <category>reverseengineering</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>binary</category>
    </item>
    <item>
      <title>How I Sync Real-Time Multiplayer Game State with Socket.io and Node.js</title>
      <dc:creator>Erol</dc:creator>
      <pubDate>Wed, 01 Apr 2026 13:46:23 +0000</pubDate>
      <link>https://dev.to/erol4/how-i-sync-real-time-multiplayer-game-state-with-socketio-and-nodejs-1d8c</link>
      <guid>https://dev.to/erol4/how-i-sync-real-time-multiplayer-game-state-with-socketio-and-nodejs-1d8c</guid>
      <description>&lt;p&gt;I built a multiplayer board game that runs in the browser. Keeping everyone's screen in sync was the hardest part by far.&lt;/p&gt;

&lt;p&gt;Here's how I did it in &lt;a href="https://tilelord.com" rel="noopener noreferrer"&gt;TileLord&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server owns everything
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Never trust the client.&lt;/strong&gt; The client sends what the player wants to do. The server checks if it's valid, updates the game, and tells everyone what happened.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Client&lt;/span&gt;
&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;placeTile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Server&lt;/span&gt;
&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;placeTile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tryPlaceTile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tilePlaced&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;actionRejected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&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;Even for a chill board game, people will try to mess with the payloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  One room per game
&lt;/h2&gt;

&lt;p&gt;Each game gets its own Socket.io room. Players join when they enter, and updates only go to that room.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`game:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`game:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gameStateUpdate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sanitizedState&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Don't send everything though.&lt;/strong&gt; If your game has hidden info (like a tile deck), strip that out before sending. Each player should only see what they're allowed to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Log every action
&lt;/h2&gt;

&lt;p&gt;I store every move as an event instead of just keeping the current state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;actionLog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TILE_PLACED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;playerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;You get a lot from this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Replays&lt;/strong&gt; - play the log forward&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconnects&lt;/strong&gt; - rebuild what a player missed while offline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debugging&lt;/strong&gt; - replay the exact sequence that caused a bug&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The replay feature took about a day to build and players use it all the time. Worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disconnects are normal
&lt;/h2&gt;

&lt;p&gt;People lose wifi. Their phone locks. Their laptop sleeps. Handle this from day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turn timeouts:&lt;/strong&gt; if someone doesn't move in time, a bot takes their turn so the game keeps going.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;turnTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;botPlayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;takeTurn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;botMove&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getState&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;TURN_TIMEOUT_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Reconnects:&lt;/strong&gt; when they come back, send them the current state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejoinGame&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;playerId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rooms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&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="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`game:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fullState&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStateForPlayer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;playerId&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;h2&gt;
  
  
  Spectators are free
&lt;/h2&gt;

&lt;p&gt;Once you have rooms, spectators just join without being able to send game actions. They get the same updates as players.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL/DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Server decides everything. Clients just ask.&lt;/li&gt;
&lt;li&gt;Socket.io rooms, one per game.&lt;/li&gt;
&lt;li&gt;Log every action. Replays and debugging come for free.&lt;/li&gt;
&lt;li&gt;Handle disconnects early. They will happen constantly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get the sync layer right first. The rest falls into place after that.&lt;/p&gt;




&lt;p&gt;I'm building TileLord at &lt;a href="https://tilelord.com" rel="noopener noreferrer"&gt;tilelord.com&lt;/a&gt;, a free Carcassonne alternative you can play with friends or bots online.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>node</category>
      <category>gamedev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why Letting Piscina Spin Up Workers Dynamically Broke My Game Server</title>
      <dc:creator>Erol</dc:creator>
      <pubDate>Sun, 08 Feb 2026 08:07:30 +0000</pubDate>
      <link>https://dev.to/erol4/why-letting-piscina-spin-up-workers-dynamically-broke-my-game-server-359e</link>
      <guid>https://dev.to/erol4/why-letting-piscina-spin-up-workers-dynamically-broke-my-game-server-359e</guid>
      <description>&lt;p&gt;First, the background: I'm running a Carcassonne alternative web game called &lt;a href="https://tilelord.com/" rel="noopener noreferrer"&gt;TileLord&lt;/a&gt;. It also supports singleplayer mode, so you play against bots.&lt;/p&gt;

&lt;p&gt;Bots are CPU-heavy, as they calculate heuristics for each possible move the bot could make, so an obvious way is to use threads on the server side, otherwise, your whole server will "lag" each time a bot is calculating a move (which happened when I first launched the game).&lt;/p&gt;

&lt;h2&gt;
  
  
  Worker threads and Piscina
&lt;/h2&gt;

&lt;p&gt;As mentioned, it was obvious to add threads, and chatgpt suggested that I use &lt;a href="https://github.com/piscinajs/piscina" rel="noopener noreferrer"&gt;Piscina&lt;/a&gt;. On Github, this is their repo description:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A fast, efficient Node.js Worker Thread Pool implementation&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So it makes total sense to use it. Gpt suggested using this configuration, based on the VPS I was running my server on (8 cores):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;minThreads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;maxThreads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I rebooted the server, and.. the lag was gone! It worked perfectly. Until... my game got more users, and bots started getting slow-ish again. 3 seconds for a bot move instead of 1.5s.&lt;/p&gt;

&lt;p&gt;I ran &lt;code&gt;htop&lt;/code&gt; on my server, and saw random spikes of 800% cpu utilization (all 8 cores at 100%) for a few seconds, then back to 0%. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F78pz9bkkhfn51yefif6x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F78pz9bkkhfn51yefif6x.png" alt="htop showing all 8 cores hitting 100% utilization" width="800" height="249"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which doesn't make any sense. There should be max 6 worker threads, and nothing else should consume much cpu, right?&lt;/p&gt;

&lt;p&gt;Wrong! Spawning a worker in very heavy in Nodejs, as it needs to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a new v8 isolate&lt;/li&gt;
&lt;li&gt;Parse + execute your worker's js bundle&lt;/li&gt;
&lt;li&gt;Run module initialization (&lt;code&gt;require&lt;/code&gt;/&lt;code&gt;import&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;JIT-compile hot code paths&lt;/li&gt;
&lt;li&gt;Maybe it triggers garbage collector activity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And these operations are parallelized internally, so when Piscina is spawning a new worker it &lt;em&gt;can&lt;/em&gt; hit all cpu cores at once, causing the whole server to lag for a second or two.&lt;/p&gt;

&lt;p&gt;After hard-coding both min/max threads to 6 (so the other 2 cpu cores are available to the server), my cpu consumption didn't go above 200%  (I added the fix 4.2): &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffe0wh784pqka9uszvtth.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffe0wh784pqka9uszvtth.png" alt="Cpu compute graph over last week" width="620" height="254"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson learned
&lt;/h2&gt;

&lt;p&gt;Worker creation can be far more expensive than actual worker execution (bot move compute, which atm is 50-500ms). In such case, it might make sense to pre-allocate threads:)&lt;/p&gt;

&lt;p&gt;Sometimes the bug isn’t in your algorithm, it’s in how your workers come into existence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read more
&lt;/h2&gt;

&lt;p&gt;I also wrote about how &lt;a href="https://dev.to/erol4/how-i-built-a-free-online-carcassonne-game-alt-you-can-play-in-the-browser-2mgd"&gt;How I Built a Free Online Carcassonne Game Alt You Can Play in the Browser&lt;/a&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>webdev</category>
      <category>node</category>
    </item>
    <item>
      <title>How I Built a Free Online Carcassonne Game Alt You Can Play in the Browser</title>
      <dc:creator>Erol</dc:creator>
      <pubDate>Thu, 09 Oct 2025 10:44:41 +0000</pubDate>
      <link>https://dev.to/erol4/how-i-built-a-free-online-carcassonne-game-alt-you-can-play-in-the-browser-2mgd</link>
      <guid>https://dev.to/erol4/how-i-built-a-free-online-carcassonne-game-alt-you-can-play-in-the-browser-2mgd</guid>
      <description>&lt;p&gt;I’ve always liked the board game Carcassonne — simple mechanics, but surprisingly deep strategy.&lt;br&gt;
A while back I started tinkering with the idea of building something similar that could be played easily in a browser with friends. That side project eventually turned into &lt;a href="https://tilelord.com" rel="noopener noreferrer"&gt;TileLord&lt;/a&gt; — a small web game where you can &lt;strong&gt;play Carcassonne online free&lt;/strong&gt; with bots or friends, no installs needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F71h6aky13jr7j4c69946.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F71h6aky13jr7j4c69946.png" alt="Gameplay" width="800" height="652"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I wanted to build it
&lt;/h2&gt;

&lt;p&gt;There aren't many online versions, it's either old Desktop apps or pay-up-front official apps. So I started building an open, Carcassonne game alternative that follows the same core rules&lt;/p&gt;

&lt;p&gt;It’s not the official game (and isn’t affiliated with it), but it scratches the same strategic itch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the hood
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt;: React + &lt;a href="https://pixijs.com/" rel="noopener noreferrer"&gt;PixiJS&lt;/a&gt; for tile rendering and UI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend&lt;/strong&gt;: Node.js + &lt;a href="https://socket.io/" rel="noopener noreferrer"&gt;Socket.io&lt;/a&gt; for multiplayer state sync&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting&lt;/strong&gt;: Cloudflare, curretnly still on free tier:)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Hard things
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Extending game engine
&lt;/h3&gt;

&lt;p&gt;The base rules are simple, but once you start adding expansions (like Inns &amp;amp; Cathedrals, River, Abbots, Farmers, etc.), the logic tree gets messy.&lt;/p&gt;

&lt;p&gt;Each feature can affect multiple parts of the scoring or tile-placement rules. I ended up refactoring the entire engine 3 times to add abstraction layers one on top of each other (eg. multi-tile buildings instead of per-tile features).&lt;/p&gt;

&lt;h3&gt;
  
  
  AI Bot heuristics
&lt;/h3&gt;

&lt;p&gt;Deciding where to place a tile, and where to place a follower, isn't as simple as eg. tic-tac-toe solver. You have to account for how many followers you still have, how many open edges of a building, defensibility of your buildings, trying to merge (attack) with other larger buildings, trying to "trap" enemies so they can't complete their cities, calculate what's the chance required tile will be drawn, etc. Many rules/weights that need to be tuned, and additional complexity arises when you add expansions into the base game.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bot optimizations
&lt;/h3&gt;

&lt;p&gt;At first it just brute-forced every legal move, deep-copying the full board to estimate the diff in value (which can be score, trapped follower, etc) for each move. But after &amp;gt;50 placed tiles, such algo takes quite some time, up to a point where you are waiting 20+ seconds for a bot move. &lt;/p&gt;

&lt;p&gt;By adding just a state diff and rollbacks (placing/removing followers or tiles), bots required an order of magnitude less compute time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2uz9pelizvx0id19c04t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2uz9pelizvx0id19c04t.png" alt="Big board, replay mode" width="800" height="510"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Current state &amp;amp; whats next
&lt;/h2&gt;

&lt;p&gt;Right now, TileLord supports all the base rules plus several expansions. Multiplayer, Replay mode, AI bots, and mobile browser play are all working.&lt;/p&gt;

&lt;p&gt;I plan to add additional expansions (major and mini ones), beginner tutorials, game/turn analysis, better difficulty tuning for AI, and more!&lt;/p&gt;

&lt;p&gt;PS. We're &lt;a href="https://dev.to/erol4/looking-for-senior-fullstack-developer-12eo"&gt;hiring&lt;/a&gt;!&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>startup</category>
      <category>webdev</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>Looking for Senior Fullstack Developer</title>
      <dc:creator>Erol</dc:creator>
      <pubDate>Fri, 26 Sep 2025 10:04:07 +0000</pubDate>
      <link>https://dev.to/erol4/looking-for-senior-fullstack-developer-12eo</link>
      <guid>https://dev.to/erol4/looking-for-senior-fullstack-developer-12eo</guid>
      <description>&lt;p&gt;About Us&lt;br&gt;
At &lt;a href="https://tilelord.com" rel="noopener noreferrer"&gt;TileLord&lt;/a&gt; we’re building a free, web-based alternative to Carcassonne. We’re a small startup with a fast-paced culture and ambitious goals.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftmtctpvzcmmad7vuk8gs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftmtctpvzcmmad7vuk8gs.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Role&lt;/strong&gt;&lt;br&gt;
We’re looking for a Senior Fullstack Developer (remote, CET timezone) with 5+ years of experience. You’ll work across frontend, backend, and database, with a big focus on PIXI.js for gameplay. Game dev experience is a plus.&lt;/p&gt;

&lt;p&gt;Requirements&lt;br&gt;
    • 5+ years fullstack experience&lt;br&gt;
    • Strong FE/BE/DB skills&lt;br&gt;
    • Proficiency in TypeScript&lt;br&gt;
    • PIXI.js or similar rendering/game framework experience&lt;br&gt;
    • Remote-friendly, CET working hours&lt;/p&gt;

&lt;p&gt;Why Join&lt;br&gt;
    • Startup culture with real ownership&lt;br&gt;
    • Fully remote&lt;br&gt;
    • Competitive pay + possible equity&lt;br&gt;
    • Build something fun and free for the gaming community&lt;/p&gt;

&lt;p&gt;Apply&lt;br&gt;
Send your CV + GitHub link to &lt;a href="mailto:erol@tilelord.com"&gt;erol@tilelord.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>hiring</category>
      <category>career</category>
    </item>
  </channel>
</rss>
