<?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: Vladimir Ushakov</title>
    <description>The latest articles on DEV Community by Vladimir Ushakov (@straightfunctor).</description>
    <link>https://dev.to/straightfunctor</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%2F4071251%2F6d3af3c9-fdb6-4873-a740-81b79b86194f.png</url>
      <title>DEV Community: Vladimir Ushakov</title>
      <link>https://dev.to/straightfunctor</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/straightfunctor"/>
    <language>en</language>
    <item>
      <title>Show DEV: A safer intake flow for digital subscription requests</title>
      <dc:creator>Vladimir Ushakov</dc:creator>
      <pubDate>Sat, 19 Sep 2026 04:27:31 +0000</pubDate>
      <link>https://dev.to/straightfunctor/show-dev-a-safer-intake-flow-for-digital-subscription-requests-4fkc</link>
      <guid>https://dev.to/straightfunctor/show-dev-a-safer-intake-flow-for-digital-subscription-requests-4fkc</guid>
      <description>&lt;p&gt;When a user cannot pay for a digital subscription, the obvious product move is to ask for more details. That is also where a small support form can become risky: people may volunteer passwords, card data, or one-time codes before anyone has explained what is actually needed.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;Оплата.Сервис&lt;/strong&gt; around the opposite constraint: collect the minimum needed to triage a request, then continue the conversation without asking for account secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The intake model
&lt;/h2&gt;

&lt;p&gt;The first step asks for only two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the service (for example ChatGPT, Apple/iCloud, Steam, PlayStation, Google Play/One, or another service);&lt;/li&gt;
&lt;li&gt;a Telegram contact so the team can respond after checking the specific case.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The form does &lt;strong&gt;not&lt;/strong&gt; ask for a password, card number, CVV/CVC, SMS code, or sign-in confirmation code. It also avoids promising that every service can be handled. A request is reviewed first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Product decisions behind the form
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Data minimization is part of the interface
&lt;/h3&gt;

&lt;p&gt;A warning hidden in a privacy policy is too late. The safe boundary should be visible at the moment the user is deciding what to submit.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. “Other service” is structured, not a blank support message
&lt;/h3&gt;

&lt;p&gt;Users can name a service that is not in the preset list, but the first step still stays narrow. This reduces the chance that a free-text box turns into a dump of sensitive account information.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. No certainty before review
&lt;/h3&gt;

&lt;p&gt;The UI says the team will check the possibility for the specific request. It does not promise a result, a price, or a deadline before the context is known.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The contact channel is explicit
&lt;/h3&gt;

&lt;p&gt;The user knows where the follow-up will happen. That makes it easier to recognize an unexpected contact or a request for information the service says it never needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would like feedback on
&lt;/h2&gt;

&lt;p&gt;The live flow is in Russian and intentionally short. I would especially value feedback from developers and product designers on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether the “never send secrets” boundary appears early enough;&lt;/li&gt;
&lt;li&gt;whether the service selection is clearer than a generic support textarea;&lt;/li&gt;
&lt;li&gt;what additional abuse-prevention cue could help without making the form feel alarming;&lt;/li&gt;
&lt;li&gt;whether the review-first wording sets the right expectation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can try the current flow here: &lt;a href="https://app.straightfunctor.xyz/?utm_source=devto&amp;amp;utm_medium=organic_forum&amp;amp;utm_campaign=cycle_06_forums&amp;amp;utm_content=minimal_intake_showdev" rel="noopener noreferrer"&gt;Оплата.Сервис&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Disclosure: I am connected with the project. This post was drafted with AI assistance and reviewed for accuracy and compliance with the DEV Community AI guidelines.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>When One Boolean Changes Five Things: Keeping Mid-Match Rules in Sync</title>
      <dc:creator>Vladimir Ushakov</dc:creator>
      <pubDate>Thu, 27 Aug 2026 06:19:46 +0000</pubDate>
      <link>https://dev.to/straightfunctor/when-one-boolean-changes-five-things-keeping-mid-match-rules-in-sync-5hj9</link>
      <guid>https://dev.to/straightfunctor/when-one-boolean-changes-five-things-keeping-mid-match-rules-in-sync-5hj9</guid>
      <description>&lt;p&gt;Cat Laundry is a browser strategy duel played on a shared 4×4 board. A move begins by clicking one of your own cats. The clicked cat switches to the opponent, while your color travels along the diagonals and may complete a square, diamond, or cross.&lt;/p&gt;

&lt;p&gt;The late-game mode, Spin Cycle, changes the match after either player reaches 70 points. Cat rotation becomes much faster, squares and diamonds stop scoring, only crosses remain valid, every cross is worth 10 points, and the winning target stays at 100.&lt;/p&gt;

&lt;p&gt;The difficult implementation detail was not the threshold. It was preventing five different parts of the game from disagreeing about whether that threshold had been crossed.&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%2Fpvdmnple2vsf723v902l.jpg" 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%2Fpvdmnple2vsf723v902l.jpg" alt="A Cat Laundry match on the 4×4 board" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Change the state after the scoring event
&lt;/h2&gt;

&lt;p&gt;The computer match keeps the late-game state as one boolean:&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;finalPhase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;activateFinalPhase&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="nx"&gt;finalPhase&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;matchTarget&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nb"&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="nx"&gt;humanScore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aiScore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;finalPhase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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 &lt;code&gt;matchTarget &amp;lt; 100&lt;/code&gt; guard matters because the first computer match can be a shorter introduction. Spin Cycle belongs to the regular 100-point match, not the 60-point trial.&lt;/p&gt;

&lt;p&gt;More importantly, I call &lt;code&gt;activateFinalPhase()&lt;/code&gt; only after the current move has found its patterns and awarded their points. The move that reaches 70 is evaluated under the rules that were active when the move began. Spin Cycle applies to the state that follows it.&lt;/p&gt;

&lt;p&gt;That ordering gives the transition a clean causal chain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the diagonal wave finishes;&lt;/li&gt;
&lt;li&gt;completed figures are detected;&lt;/li&gt;
&lt;li&gt;points are added;&lt;/li&gt;
&lt;li&gt;the score crosses 70;&lt;/li&gt;
&lt;li&gt;Spin Cycle is announced;&lt;/li&gt;
&lt;li&gt;later moves use the new scoring rules.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The function also returns whether this specific scoring event started the phase. That is different from merely checking whether &lt;code&gt;finalPhase&lt;/code&gt; is true. The UI needs a one-time transition message, while the board needs a persistent late-game appearance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let scoring consume the state directly
&lt;/h2&gt;

&lt;p&gt;All figure detection still passes through &lt;code&gt;completedPatterns()&lt;/code&gt;. Spin Cycle narrows that existing pipeline instead of adding a second scoring system:&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="k"&gt;for &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;pattern&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;PATTERNS&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="nx"&gt;finalPhase&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;КРЕСТ&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scoredPattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;finalPhase&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="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;points&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Find matching placements that include a cell touched by this move.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where the rule has to be authoritative. In late game, square and diamond candidates never enter the accepted set. Both cross variants survive, and their points are replaced with 10.&lt;/p&gt;

&lt;p&gt;Everything after detection can remain unchanged: overlapping figures are resolved, points are summed, the scored cells are recycled, and the next turn begins. The UI labels also come from the accepted pattern objects, so a late-game cross is displayed as &lt;code&gt;+10&lt;/code&gt; because the scoring object itself contains 10. There is no separate display-only value to drift out of sync.&lt;/p&gt;

&lt;p&gt;The computer opponent uses the same function while simulating candidate moves:&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;function&lt;/span&gt; &lt;span class="nf"&gt;simulateStrike&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="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Apply the clicked-cat sacrifice and diagonal recoloring.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;touched&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;patterns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;completedPatterns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;touched&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&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;That means its move evaluation changes automatically when &lt;code&gt;finalPhase&lt;/code&gt; changes. I do not maintain one scoring table for the real move and another for the AI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the copy and animation read the same state
&lt;/h2&gt;

&lt;p&gt;The renderer maps the boolean to a persistent CSS class and to the visible banner:&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="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;final-phase&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;finalPhase&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;finalPhase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;finalPhase&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The class changes several visual channels together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.machine-grid.final-phase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;saturate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1.45&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;contrast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1.08&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.machine-grid.final-phase&lt;/span&gt; &lt;span class="nc"&gt;.cat-wrap&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation-duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.05s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.machine-grid.final-phase&lt;/span&gt; &lt;span class="nc"&gt;.glass&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;finalGlassPanic&lt;/span&gt; &lt;span class="m"&gt;.34s&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;infinite&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 normal cat rotation is seven seconds, so 1.05 seconds is an unmistakable change. Saturation, flashing glass, and faster lamps reinforce the same state without changing the underlying board.&lt;/p&gt;

&lt;p&gt;The banner states the scoring rule explicitly: Spin Mode activated; only crosses score; each cross is worth 10 points. The English strings live in the same translation map as the rest of the interface, including the 70-point announcement. This matters because an untranslated or stale rule sentence would be a functional bug: it could tell the player that squares still count while the scoring function has already rejected them.&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%2Fmxjeyky6uo3mb13iwn3i.jpg" 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%2Fmxjeyky6uo3mb13iwn3i.jpg" alt="The diagonal wave tutorial in Cat Laundry" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the network match server-authoritative
&lt;/h2&gt;

&lt;p&gt;The multiplayer implementation has its own match object, but not its own interpretation of the rule. The server owns &lt;code&gt;finalPhase&lt;/code&gt;, sets it after scoring, and stores when the transition began:&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;const&lt;/span&gt; &lt;span class="nx"&gt;phaseStarted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;finalPhase&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nb"&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="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;LEFT&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;RIGHT&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;70&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;phaseStarted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;finalPhase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;finalPhaseStartedAt&lt;/span&gt; &lt;span class="o"&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;Server-side &lt;code&gt;completedPatterns()&lt;/code&gt; applies the same cross-only filter and 10-point override. The serialized match then sends both persistent and short-lived information:&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;finalPhase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;finalPhase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;finalPhaseAnnouncing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;finalPhaseStartedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;finalPhaseStartedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client uses &lt;code&gt;finalPhase&lt;/code&gt; for the banner, fast animation, and continuing visual state. It uses &lt;code&gt;finalPhaseAnnouncing&lt;/code&gt; for the temporary 70-point message. Scoring never depends on whether a client rendered that message in time; the server has already decided which figures count.&lt;/p&gt;

&lt;p&gt;This separation is useful beyond games. A state transition often has two presentation needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a durable indication of the current mode;&lt;/li&gt;
&lt;li&gt;a short explanation of the event that changed the mode.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Trying to derive both from a timer or animation callback would make presentation timing responsible for game truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invariant I actually care about
&lt;/h2&gt;

&lt;p&gt;Spin Cycle is safe only if these statements are always true together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;if the banner says cross-only, the detector rejects squares and diamonds;&lt;/li&gt;
&lt;li&gt;if the detector awards 10, the figure label also says &lt;code&gt;+10&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;if cats are spinning faster, the match is already in late game;&lt;/li&gt;
&lt;li&gt;the AI evaluates the same late-game moves that the real scorer accepts;&lt;/li&gt;
&lt;li&gt;in multiplayer, the server decides the phase and clients only present it;&lt;/li&gt;
&lt;li&gt;changing the allowed figures never changes the 100-point victory condition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code is small because the transition has one source of truth and several explicit consumers. The work is in making every consumer read that truth at the correct moment.&lt;/p&gt;

&lt;p&gt;You can &lt;a href="https://straightfunctor.xyz/cat-laundry/?lang=en&amp;amp;source=devto" rel="noopener noreferrer"&gt;play Cat Laundry in the browser&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;When an interface changes rules while an interaction is still in progress, do you prefer a persistent mode indicator, a one-time explanation, or both?&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>ux</category>
    </item>
    <item>
      <title>One Flag, Three Consequences: Switching Scoring Rules Mid-Match</title>
      <dc:creator>Vladimir Ushakov</dc:creator>
      <pubDate>Fri, 21 Aug 2026 06:49:32 +0000</pubDate>
      <link>https://dev.to/straightfunctor/one-flag-three-consequences-switching-scoring-rules-mid-match-3kd1</link>
      <guid>https://dev.to/straightfunctor/one-flag-three-consequences-switching-scoring-rules-mid-match-3kd1</guid>
      <description>&lt;p&gt;Cat Laundry is a 4×4 strategy duel. A move starts by clicking one of your own cats. That cat changes to the opponent's color, while your color propagates along the four diagonals. When the wave stops, completed squares, diamonds, and crosses are scored.&lt;/p&gt;

&lt;p&gt;That was already an unusual rule set. The late game added a second problem: how do you change scoring rules during a live match without making the result feel arbitrary?&lt;/p&gt;

&lt;p&gt;The rule I shipped is called &lt;strong&gt;Spin Cycle&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;when either player reaches 70 points, late game begins;&lt;/li&gt;
&lt;li&gt;squares and diamonds stop scoring;&lt;/li&gt;
&lt;li&gt;only crosses count, for 10 points each;&lt;/li&gt;
&lt;li&gt;the winning target remains 100;&lt;/li&gt;
&lt;li&gt;cat rotation becomes visibly faster.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important part is not the threshold itself. It is making the state transition, scoring code, and visual feedback describe the same event.&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%2F4k6vpulwpngbbv3kkk02.jpg" 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%2F4k6vpulwpngbbv3kkk02.jpg" alt="A Cat Laundry match on the 4×4 board" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Change state after scoring, not before
&lt;/h2&gt;

&lt;p&gt;The match keeps one boolean:&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;finalPhase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;activateFinalPhase&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="nx"&gt;finalPhase&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;matchTarget&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&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="nx"&gt;humanScore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aiScore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;70&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;finalPhase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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 timing matters. I call this only after the current move has finished finding patterns and adding their points.&lt;/p&gt;

&lt;p&gt;That means the move which takes a player from 68 to 73 is evaluated under the original rules. Only the following state is late game. If I switched the flag while the wave was still resolving, the same move could begin under one scoring table and finish under another.&lt;/p&gt;

&lt;p&gt;The return value is useful too. &lt;code&gt;true&lt;/code&gt; does not merely mean that late game is active; it means that this exact scoring event started it. The UI can therefore announce the transition once instead of repeating a banner after every later move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the rule filter next to pattern detection
&lt;/h2&gt;

&lt;p&gt;Squares, diamonds, and crosses all pass through &lt;code&gt;completedPatterns()&lt;/code&gt;. Spin Cycle does not add a parallel scoring system. It narrows the existing candidate list:&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="k"&gt;for &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;pattern&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;PATTERNS&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="nx"&gt;finalPhase&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;КРЕСТ&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scoredPattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;finalPhase&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="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;points&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Find placements that match the board and touch this move.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives the state flag two explicit effects in one place:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;non-cross patterns are ignored;&lt;/li&gt;
&lt;li&gt;every surviving cross is worth 10.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rest of the scoring pipeline stays unchanged: overlapping figures are resolved, points are summed, used cells are recycled, and the next turn begins.&lt;/p&gt;

&lt;p&gt;Keeping the filter here also makes AI simulation use the same rules. The AI evaluates candidate moves through the same pattern function instead of maintaining a second late-game scoring table that could drift away from the real match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Visual feedback must read the same flag
&lt;/h2&gt;

&lt;p&gt;The board renderer applies a &lt;code&gt;final-phase&lt;/code&gt; class from that same boolean. CSS makes the washing-machine motion more aggressive, while the score event that flips the flag replaces the normal status copy with a direct explanation:&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;const&lt;/span&gt; &lt;span class="nx"&gt;phaseStarted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;activateFinalPhase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;phaseStarted&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;70 POINTS · SPIN MODE ACTIVATED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;normalStatus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;phaseStarted&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ONLY CROSSES SCORE · EACH CROSS +10 POINTS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;normalLog&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a causal sequence the player can follow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;a move completes;&lt;/li&gt;
&lt;li&gt;points are awarded;&lt;/li&gt;
&lt;li&gt;a score reaches 70;&lt;/li&gt;
&lt;li&gt;the transition message appears;&lt;/li&gt;
&lt;li&gt;the board accelerates;&lt;/li&gt;
&lt;li&gt;future scoring accepts only crosses.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The animation is not decoration. It is a persistent reminder that the scoring function is now in a different mode.&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%2Foa10b2nqiuv1zzmxavpk.jpg" 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%2Foa10b2nqiuv1zzmxavpk.jpg" alt="The tutorial showing a diagonal wave" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why keep the target at 100?
&lt;/h2&gt;

&lt;p&gt;Changing the scoring system and the victory target at the same time would create two moving references. Keeping 100 preserves the player's existing progress while the set of useful shapes becomes narrower.&lt;/p&gt;

&lt;p&gt;A 10-point cross is also large enough to make a late reversal possible. The leading player cannot simply wait for the score to finish itself: both players still need to build or interrupt a cross.&lt;/p&gt;

&lt;h2&gt;
  
  
  One state, three consumers
&lt;/h2&gt;

&lt;p&gt;Spin Cycle became easier to reason about once I treated it as a state transition with three consumers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;game logic&lt;/strong&gt; filters patterns and changes cross value;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;presentation&lt;/strong&gt; accelerates the cats and keeps a visible late-game style;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;event copy&lt;/strong&gt; explains exactly when and why the transition happened.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any one of those consumers derived late game independently, the match could display Spin Cycle while scoring a square, or score a 10-point cross before the player saw the rule change.&lt;/p&gt;

&lt;p&gt;You can &lt;a href="https://straightfunctor.xyz/cat-laundry/?lang=en&amp;amp;source=devto" rel="noopener noreferrer"&gt;play Cat Laundry in the browser&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;When a game changes rules mid-match, what visual signal would make you trust that the new scoring mode has actually taken effect?&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>ux</category>
    </item>
    <item>
      <title>Changing the Rules at 70 Points: Designing a Legible Endgame</title>
      <dc:creator>Vladimir Ushakov</dc:creator>
      <pubDate>Sun, 16 Aug 2026 06:53:52 +0000</pubDate>
      <link>https://dev.to/straightfunctor/changing-the-rules-at-70-points-designing-a-legible-endgame-lg</link>
      <guid>https://dev.to/straightfunctor/changing-the-rules-at-70-points-designing-a-legible-endgame-lg</guid>
      <description>&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%2F0g7b2yv4uypwra2m3h8x.jpg" 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%2F0g7b2yv4uypwra2m3h8x.jpg" alt="A Cat Laundry match on the 4×4 board" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Cat Laundry already had an unusual rule: clicking one of your cats gives that cell to the opponent, while your color travels diagonally from it. Squares, diamonds, and crosses score points. The interaction is simple to execute, but the result is not self-explanatory.&lt;/p&gt;

&lt;p&gt;The newest change made that communication problem sharper. I added a late-game state called &lt;strong&gt;Spin Cycle&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When either player reaches 70 points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Spin Cycle visibly activates;&lt;/li&gt;
&lt;li&gt;the cats rotate much faster;&lt;/li&gt;
&lt;li&gt;squares and diamonds stop scoring;&lt;/li&gt;
&lt;li&gt;only crosses count;&lt;/li&gt;
&lt;li&gt;every cross awards 10 points;&lt;/li&gt;
&lt;li&gt;the winning score remains 100.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The purpose is mechanical, not cosmetic. A normal match can slow down near the end because both players understand more of the board and start protecting useful formations. Spin Cycle makes the last 30 points more decisive. It also leaves room for a sharp comeback: one cross is worth a third of the remaining distance to victory when the mode begins.&lt;/p&gt;

&lt;p&gt;That creates a UX problem: how do you change the scoring rules during play without forcing the player through another tutorial?&lt;/p&gt;

&lt;h2&gt;
  
  
  A rule change must look like an event
&lt;/h2&gt;

&lt;p&gt;The implementation has one explicit transition. After points are awarded, the game checks the higher score. If it is at least 70, the target is the normal 100-point target, and Spin Cycle has not started yet, the state flips once.&lt;/p&gt;

&lt;p&gt;In simplified form:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;spinCycle&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&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="nx"&gt;playerScore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opponentScore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;spinCycle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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 important part is not the conditional. It is what happens around it.&lt;/p&gt;

&lt;p&gt;A silent boolean would be correct game logic and poor interface logic. If a square scores on one turn and does nothing on the next, the player is likely to read that as a bug. The new state therefore changes several signals together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a high-contrast banner announces the mode;&lt;/li&gt;
&lt;li&gt;the status text says that only crosses now count and that each is worth 10;&lt;/li&gt;
&lt;li&gt;the board becomes more saturated;&lt;/li&gt;
&lt;li&gt;the washing-machine glass and indicator lights pulse faster;&lt;/li&gt;
&lt;li&gt;every cat's rotation accelerates dramatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The player should not have to infer a scoring-table change from a missing number. The board itself needs to say: &lt;em&gt;the match just entered a different phase&lt;/em&gt;.&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%2F5w5ibjugudclb8md2ykk.jpg" 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%2F5w5ibjugudclb8md2ykk.jpg" alt="A completed shape highlighted after recoloring" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the scoring filter happens after the transition
&lt;/h2&gt;

&lt;p&gt;The existing scoring pipeline detects completed shapes and returns their point values. Spin Cycle does not replace shape detection. It filters the detected results: outside Spin Cycle all supported shapes can score; inside it, only crosses survive the filter and each surviving cross is worth 10.&lt;/p&gt;

&lt;p&gt;Keeping detection and late-game scoring separate matters for feedback. A square can still exist visually after 70 points. It simply is no longer a scoring object. If I removed square detection entirely, other parts of the visual explanation could drift away from what is actually on the board.&lt;/p&gt;

&lt;p&gt;The transition is checked after the points from the current action are applied. That means the move that reaches 70 finishes under the old scoring state; then the interface announces Spin Cycle for what follows. The player sees a cause, a boundary, and the new consequence in that order.&lt;/p&gt;

&lt;h2&gt;
  
  
  The animation has to carry causality
&lt;/h2&gt;

&lt;p&gt;This game already taught me that instant state updates can look like random flicker. One click may recolor the clicked cat, propagate diagonally, complete a shape, award points, and hand control to the other side. If all of those facts appear at once, the final board is visible but the move is not understandable.&lt;/p&gt;

&lt;p&gt;Spin Cycle adds another possible consequence to the same chain. The readable order is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the action resolves;&lt;/li&gt;
&lt;li&gt;the current shape is scored;&lt;/li&gt;
&lt;li&gt;the score crosses 70;&lt;/li&gt;
&lt;li&gt;the mode announcement appears;&lt;/li&gt;
&lt;li&gt;the board adopts its faster visual rhythm;&lt;/li&gt;
&lt;li&gt;later scoring accepts crosses only.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The animations are not decoration between states. They are the grammar that connects those states.&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%2Fzpsbls1gutiyziqw0rfj.jpg" 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%2Fzpsbls1gutiyziqw0rfj.jpg" alt="The tutorial showing the path of a diagonal wave" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  No second tutorial, but no hidden rule either
&lt;/h2&gt;

&lt;p&gt;I did not add a modal tutorial at 70. Interrupting a close match to explain the ending would fight the tension the mode is supposed to create.&lt;/p&gt;

&lt;p&gt;Instead, the transition repeats the rule in the normal status area while changing the whole board's tempo. This is a contextual explanation: it appears exactly when it becomes relevant and does not ask for confirmation.&lt;/p&gt;

&lt;p&gt;That is also why the faster spinning alone would not be enough. Motion tells the player that something changed, but it does not tell them &lt;em&gt;what counts now&lt;/em&gt;. Text alone has the opposite problem: it is precise, but easy to miss during a match. The two signals need each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real test is the first wrong expectation
&lt;/h2&gt;

&lt;p&gt;The most useful observation will not be whether players notice the banner. It will be what they do immediately afterward.&lt;/p&gt;

&lt;p&gt;Do they try to complete a square and become confused when it awards nothing? Do they understand that a cross is now worth 10? Does the accelerated motion feel like a clear endgame signal or just visual noise? And after they understand the rule, does the possibility of a comeback make them want another match?&lt;/p&gt;

&lt;p&gt;You can &lt;a href="https://straightfunctor.xyz/cat-laundry/?lang=en&amp;amp;source=devto" rel="noopener noreferrer"&gt;play the current English build in the browser&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;After the score reaches 70, is it clear from the visual signals that the rules have changed and only crosses score now?&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>ux</category>
    </item>
    <item>
      <title>I Built the Game Logic Quickly. Making Players Understand It Was Much Harder</title>
      <dc:creator>Vladimir Ushakov</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:09:03 +0000</pubDate>
      <link>https://dev.to/straightfunctor/i-built-the-game-logic-quickly-making-players-understand-it-was-much-harder-1agc</link>
      <guid>https://dev.to/straightfunctor/i-built-the-game-logic-quickly-making-players-understand-it-was-much-harder-1agc</guid>
      <description>&lt;p&gt;Cat Laundry is a small browser strategy game played on a 4×4 board. The rule that drives it fits in a few sentences. Explaining the consequences of that rule on screen took much longer than implementing it.&lt;/p&gt;

&lt;p&gt;The board contains sixteen cats, each belonging to one of two colors. On your turn, you click a cat of your color. The clicked cat changes to the opponent's color, while your color spreads outward along all four diagonals. If the resulting cells form a square, diamond, or cross, that shape scores.&lt;/p&gt;

&lt;p&gt;That sounded readable when I described it in text. My first visual implementation proved otherwise.&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%2F43ta3b5fa60wqmia8ov6.jpg" 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%2F43ta3b5fa60wqmia8ov6.jpg" alt="A Cat Laundry match on the 4×4 board" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A move is not one state change
&lt;/h2&gt;

&lt;p&gt;The simple implementation is to calculate the final board, assign it, and render once. From the program's perspective, that is attractive: input goes in, the rule function returns the next state, and the UI displays it.&lt;/p&gt;

&lt;p&gt;From a player's perspective, several cats flash to new colors for no visible reason.&lt;/p&gt;

&lt;p&gt;The important information is not only &lt;em&gt;which&lt;/em&gt; cells changed. It is the causal chain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I chose this cat.&lt;/li&gt;
&lt;li&gt;That cat was sacrificed to the other color.&lt;/li&gt;
&lt;li&gt;My color moved through the nearest diagonal cells.&lt;/li&gt;
&lt;li&gt;It continued to the next diagonal distance.&lt;/li&gt;
&lt;li&gt;The last recolored cell completed a shape.&lt;/li&gt;
&lt;li&gt;The shape scored and was redistributed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If I render only the final array, steps 2–5 collapse into one frame. The result is mathematically correct and visually indistinguishable from random blinking.&lt;/p&gt;

&lt;p&gt;The current move code therefore groups diagonal targets by their distance from the clicked cell:&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;const&lt;/span&gt; &lt;span class="nx"&gt;distances&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)))&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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&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;ringIndex&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;ringIndex&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;distances&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;ringIndex&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;distance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;distances&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ringIndex&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;ring&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;distance&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;waveNext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ring&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ringIndex&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="mi"&gt;120&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;waveNext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;recolor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ring&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;render&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 structure is: announce the next ring, wait, apply it, render, then continue. &lt;code&gt;waveNext&lt;/code&gt;, &lt;code&gt;waveReached&lt;/code&gt;, &lt;code&gt;waveOrigin&lt;/code&gt;, and the current actor are presentation state, not game rules. They exist because the player needs to see where the change came from and where it is going.&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%2F7bomg2t93tgrmibcz7gp.jpg" 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%2F7bomg2t93tgrmibcz7gp.jpg" alt="The tutorial showing the path of a diagonal wave" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape highlight has to arrive late
&lt;/h2&gt;

&lt;p&gt;I also had to separate "the board now contains a shape" from "show the scored shape."&lt;/p&gt;

&lt;p&gt;Shape detection itself is straightforward. I slide templates over the 4×4 board: two forms of cross, a diamond, and a 2×2 square. A candidate counts only when every required cell has the scoring color and at least one of those cells was touched by the current move. Candidates are sorted by points, and overlapping lower-value shapes are rejected.&lt;/p&gt;

&lt;p&gt;The timing was the difficult part.&lt;/p&gt;

&lt;p&gt;If the highlight appears in the same render as the final recolor, it competes with the event that caused it. A player sees a colored outline and a changed cat, but cannot reliably tell which happened first. The UI is effectively presenting cause and conclusion at once.&lt;/p&gt;

&lt;p&gt;After the final diagonal ring, the game now waits &lt;code&gt;650 ms&lt;/code&gt; before setting &lt;code&gt;activePattern&lt;/code&gt;. Only then does it show the complete shape and add its points. It holds that state for another &lt;code&gt;1000 ms&lt;/code&gt; before redistributing the scored cells roughly between the two colors.&lt;/p&gt;

&lt;p&gt;Those waits are not decorative polish. They turn one opaque transition into three statements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the last cells changed;&lt;/li&gt;
&lt;li&gt;this exact shape is now complete;&lt;/li&gt;
&lt;li&gt;these cells are being recycled because the shape was consumed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule engine does not need those phases. The person watching it does.&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%2F1xwpl38qz1wlj86gct1j.jpg" 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%2F1xwpl38qz1wlj86gct1j.jpg" alt="A completed shape highlighted after recoloring" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Interruption made animation part of the game state
&lt;/h2&gt;

&lt;p&gt;The delay between diagonal rings created a tactical possibility: another player can move before the previous inertia reaches its outer cells.&lt;/p&gt;

&lt;p&gt;When that happens, the game does not simply discard the earlier action. It increments a generation token so the old asynchronous loop can no longer continue, evaluates shapes using the cells that wave actually touched, fixes any score, and starts the new move.&lt;/p&gt;

&lt;p&gt;In the single-player code, each animated strike captures a local token. After every wait it checks that token against the current &lt;code&gt;waveToken&lt;/code&gt;. Starting an interrupt increments the token. The suspended strike wakes up, sees that it belongs to an older generation, and returns.&lt;/p&gt;

&lt;p&gt;This was useful beyond cancellation. It gave the mechanic an honest visual contract: if the wave has not visibly reached a cell, that cell is not silently included in the result.&lt;/p&gt;

&lt;p&gt;It also explains why the AI cannot answer immediately. The move selector can rank legal sources synchronously, but an instant computer response would erase the reading window that makes interruption understandable. AI actions are deliberately scheduled with a random delay between &lt;code&gt;1200&lt;/code&gt; and &lt;code&gt;2200 ms&lt;/code&gt;. The delay is a gameplay constraint and a readability constraint, not an attempt to imitate expensive computation.&lt;/p&gt;

&lt;p&gt;The AI itself evaluates simulated strikes. Easy mode chooses randomly. The other modes rank candidates using immediate shape points, converted cells, diagonal reach, and—on the stronger evaluation path—the opponent's best immediate reply. None of that matters if the player cannot see what move the AI interrupted, so the pause is as important as the score function.&lt;/p&gt;

&lt;h2&gt;
  
  
  A rules modal was not enough
&lt;/h2&gt;

&lt;p&gt;My original explanation treated the move as a sentence: click your cat, sacrifice it, recolor the diagonals, make shapes. Test players still missed either the sacrifice or the propagation.&lt;/p&gt;

&lt;p&gt;The tutorial became an eleven-step interactive sequence with its own small state machine. It does not merely show completed boards. It asks for a legal click, animates diagonal distances, pauses on the last recolored vertex, and only then highlights the square, diamond, or cross. Later steps stage an opponent's partial wave and ask the player to interrupt it. Separate examples explain overlapping shapes, independent shapes, safe opponent waves, and the eight-second turn limit.&lt;/p&gt;

&lt;p&gt;That sounds more elaborate than the underlying game because it is. A novel interaction often needs examples ordered by dependency. I cannot usefully explain interruption until the player can identify a wave in progress. I cannot explain a scored cross until the player has seen the final vertex change before the highlight appears.&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%2Fky24cae5mzeikuidw0pq.jpg" 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%2Fky24cae5mzeikuidw0pq.jpg" alt="The step-by-step Cat Laundry tutorial" width="800" height="733"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Multiplayer had to transmit the unfinished move
&lt;/h2&gt;

&lt;p&gt;For online matches, sending only the final board would recreate the original blinking problem on the second client. The server is authoritative, but its serialized match includes the visual intermediate state:&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;board&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;waveOrigin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waveOrigin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;activeEmitter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeEmitter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;waveReached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waveReached&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;waveNext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waveNext&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;waveFront&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waveFront&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;activePattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activePattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;transitioning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transitionUntil&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;serverTime&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;The client polls the match endpoint every &lt;code&gt;240 ms&lt;/code&gt; and re-renders when the version changes. The server bumps that version when it announces the next diagonal ring, when it applies the ring, when it fixes a pattern, and when the transition finishes. A late or interrupted strike is protected by a server-side generation counter, just like the local token.&lt;/p&gt;

&lt;p&gt;This means the intermediate animation is not reconstructed by guessing from two board snapshots. Both players receive the same origin, reached cells, next cells, active pattern, and transition lock. The causal explanation is part of synchronized state.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;p&gt;I used to think of animation state as a temporary layer placed over the real application state. Cat Laundry forced a more precise distinction.&lt;/p&gt;

&lt;p&gt;Some animation is cosmetic. A hover bounce can disappear without changing what the interface communicates. Causal animation is different: it explains how one valid state became another. If users must make decisions during that transition—or if two clients must agree on it—the intermediate state deserves explicit names, cancellation rules, and synchronization.&lt;/p&gt;

&lt;p&gt;The practical questions I now ask are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What did the user cause?&lt;/li&gt;
&lt;li&gt;Which visible change is the first consequence?&lt;/li&gt;
&lt;li&gt;What must finish before I present the conclusion?&lt;/li&gt;
&lt;li&gt;Can a new input interrupt the sequence?&lt;/li&gt;
&lt;li&gt;If it can, which intermediate facts are already committed?&lt;/li&gt;
&lt;li&gt;Would another client be able to render the same explanation?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can &lt;a href="https://straightfunctor.xyz/cat-laundry/?lang=en&amp;amp;source=devto" rel="noopener noreferrer"&gt;play Cat Laundry in the browser&lt;/a&gt;. The tutorial is skippable, but it is also the quickest way to see the sequence described above.&lt;/p&gt;

&lt;p&gt;When you try one move, is it clear which cat caused each recolor—and can you tell why a completed shape appeared before looking at the score?&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>ux</category>
    </item>
  </channel>
</rss>
