<?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: GameHzLab</title>
    <description>The latest articles on DEV Community by GameHzLab (@gamehzlab).</description>
    <link>https://dev.to/gamehzlab</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%2F3833389%2F4c32fbe2-3aa5-42d0-8304-442219520e1d.png</url>
      <title>DEV Community: GameHzLab</title>
      <link>https://dev.to/gamehzlab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gamehzlab"/>
    <language>en</language>
    <item>
      <title>I Stopped Testing Card Games One Move at a Time</title>
      <dc:creator>GameHzLab</dc:creator>
      <pubDate>Thu, 05 Nov 2026 04:27:56 +0000</pubDate>
      <link>https://dev.to/gamehzlab/i-stopped-testing-card-games-one-move-at-a-time-1c4a</link>
      <guid>https://dev.to/gamehzlab/i-stopped-testing-card-games-one-move-at-a-time-1c4a</guid>
      <description>&lt;p&gt;Suggested DEV description: A legal move is not proof that a game can finish. Here is how full-match simulations caught freezes, duplicate cards, broken round transitions, and rule bugs across five browser card games.&lt;/p&gt;

&lt;p&gt;Suggested DEV tags: &lt;code&gt;testing&lt;/code&gt;, &lt;code&gt;typescript&lt;/code&gt;, &lt;code&gt;gamedev&lt;/code&gt;, &lt;code&gt;webdev&lt;/code&gt;&lt;/p&gt;




&lt;p&gt;One of my card games once passed every rule test I had and still froze halfway through a match.&lt;/p&gt;

&lt;p&gt;The deck contained the right cards. Players followed suit. Trick winners were calculated correctly. Scoring examples matched the rulebook. None of that helped when the computer reached a state where it was waiting for a player who had already been removed from the hand.&lt;/p&gt;

&lt;p&gt;That bug changed the way I test turn-based games.&lt;/p&gt;

&lt;p&gt;I still write small tests for individual rules, but I no longer treat them as evidence that a game works. A playable game has to move through every phase, survive every hand boundary, and eventually stop for the right reason.&lt;/p&gt;

&lt;p&gt;Today I run complete simulated matches for five browser games: Spades, Hearts, Euchre, Contract Bridge, and Oh Hell. The computer players do not need to be clever in these tests. They only need to make legal decisions quickly enough to expose problems in the game loop.&lt;/p&gt;

&lt;p&gt;This is the testing approach that has caught the bugs my example-based tests missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  A legal move proves very little
&lt;/h2&gt;

&lt;p&gt;Consider a follow-suit test:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;requires a player to follow the led suit&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="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;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makeState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;card&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;10H&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;hand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;card&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2H&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;card&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AS&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;playCard&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toThrow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;playCard&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2H&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeDefined&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;This is useful. It proves one rule under one arrangement of cards.&lt;/p&gt;

&lt;p&gt;It does not prove that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the next player gets a turn;&lt;/li&gt;
&lt;li&gt;the fourth card closes the trick;&lt;/li&gt;
&lt;li&gt;the correct player leads next;&lt;/li&gt;
&lt;li&gt;all cards remain accounted for;&lt;/li&gt;
&lt;li&gt;the final trick reaches scoring;&lt;/li&gt;
&lt;li&gt;the next round starts with an empty table;&lt;/li&gt;
&lt;li&gt;the match eventually ends.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of the painful bugs in my games lived in that list. Each transition looked reasonable on its own. The failure only appeared after several correct transitions were combined.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use the computer player as a test driver
&lt;/h2&gt;

&lt;p&gt;Once a rules engine accepts actions without depending on the DOM, a computer player becomes a useful test driver.&lt;/p&gt;

&lt;p&gt;Here is the basic shape of the Oh Hell simulation in my test suite:&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;let&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createGame&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;moves&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;while &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;phase&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gameSummary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;moves&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2500&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dealing&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;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;beginBidding&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="k"&gt;if &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;phase&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bidding&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;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;submitBid&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;chooseBid&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;turn&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="k"&gt;if &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;phase&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;playing&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;card&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;chooseCard&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;playCard&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="k"&gt;if &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;phase&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;collectingTrick&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;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collectTrick&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="k"&gt;if &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;phase&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;roundSummary&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;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;startNextRound&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="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&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;phase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gameSummary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;moves&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is nothing sophisticated about the loop. That is the point. It keeps asking the engine, "What phase are you in, and what legal action moves you forward?"&lt;/p&gt;

&lt;p&gt;If the state machine enters an unhandled phase, the test stops making progress. If turn ownership is wrong, the next action is rejected. If a round transition forgets to reset a field, a later invariant fails.&lt;/p&gt;

&lt;p&gt;The computer player is not being tested for skill here. It is supplying valid traffic to the engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Always give the simulation a move budget
&lt;/h2&gt;

&lt;p&gt;A test that waits forever is worse than a test that fails.&lt;/p&gt;

&lt;p&gt;Every simulation has an upper bound:&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="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&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;gameOver&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;moves&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_MOVES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;takeNextLegalAction&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="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&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;gameOver&lt;/span&gt;&lt;span class="p"&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;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;moves&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_MOVES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;match exceeded its move budget&lt;/span&gt;&lt;span class="dl"&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 number should be comfortably above a valid game's worst case, not a timeout measured in milliseconds. A move budget makes the failure deterministic and tells me something useful: the state machine did not converge.&lt;/p&gt;

&lt;p&gt;This caught an Euchre problem in lone hands. The maker's partner was meant to sit out, but one turn-advance path still landed on that seat. The UI looked like it was thinking. The simulation simply consumed its move budget and failed.&lt;/p&gt;

&lt;p&gt;The fix was to make "next active seat" a rule shared by every turn transition, rather than skipping the partner in only the visible play loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check conservation laws after every action
&lt;/h2&gt;

&lt;p&gt;Card games come with unusually good invariants. Cards should not appear, disappear, or exist in two places at once.&lt;/p&gt;

&lt;p&gt;For Hearts, I collect cards from every live location after each play:&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;cards&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="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&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;hands&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;flat&lt;/span&gt;&lt;span class="p"&gt;(),&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;currentTrick&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plays&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;play&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;play&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;),&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;completedTricks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;trick&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;trick&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plays&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;play&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;play&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;card&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;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cards&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="mi"&gt;52&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&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;cards&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;card&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;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;52&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking only at the end of a round is too late. If a card is duplicated on play 17 and silently removed on play 31, the final count may look fine. An assertion after every action identifies the transition that broke the invariant.&lt;/p&gt;

&lt;p&gt;The same idea applies to values other than cards:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Hearts round contains 13 hearts and, unless someone shoots the moon, 26 penalty points.&lt;/li&gt;
&lt;li&gt;A four-player trick has four plays; a lone Euchre trick has three.&lt;/li&gt;
&lt;li&gt;Bridge ends a played deal with 13 completed tricks.&lt;/li&gt;
&lt;li&gt;Each Oh Hell round records one bid and one result per player.&lt;/li&gt;
&lt;li&gt;Team trick totals in Spades must equal the number of completed tricks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These checks are cheap, precise, and much more useful than taking screenshots of the final score.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test phase transitions as first-class behavior
&lt;/h2&gt;

&lt;p&gt;I used to think of phases as UI labels: bidding, playing, scoring. Now I treat them as part of the public contract of the engine.&lt;/p&gt;

&lt;p&gt;A transition test should verify both sides of the boundary.&lt;/p&gt;

&lt;p&gt;When Hearts finishes its final trick, for example, I care about more than the score:&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="nf"&gt;expect&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;phase&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;round_end&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&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;completedTricks&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&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;currentTrick&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plays&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;totalCardsInHands&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="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That empty &lt;code&gt;currentTrick&lt;/code&gt; assertion matters. Without it, the next deal can begin while the previous four cards are still considered active. I found exactly that bug after the UI appeared to play the final trick twice.&lt;/p&gt;

&lt;p&gt;Round boundaries deserve their own assertions because they carry more cleanup than ordinary turns: dealer rotation, passing direction, vulnerability, scores, active seats, bids, and visible trick state may all change together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seed randomness when you need to replay a failure
&lt;/h2&gt;

&lt;p&gt;Random deals are useful because they explore arrangements I would not think to write by hand. They are also frustrating when a failing deal cannot be reproduced.&lt;/p&gt;

&lt;p&gt;For repeated engine tests, I inject a small seeded generator:&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;function&lt;/span&gt; &lt;span class="nf"&gt;seededRandom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&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;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;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;return &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;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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;imul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1664525&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1013904223&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;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;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mh"&gt;0x100000000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;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;seed&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="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;seed&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="nf"&gt;playCompleteDeal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;seededRandom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&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;If seed 14 fails, I can run only seed 14, inspect the exact deal, and turn it into a focused regression test.&lt;/p&gt;

&lt;p&gt;Production shuffling still uses cryptographic randomness. Test randomness has a different job: explore several paths while leaving a receipt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not let the test AI cheat
&lt;/h2&gt;

&lt;p&gt;Because every hand exists in browser memory, it is easy to give a computer player the entire game state. That makes the test convenient, but it can hide bugs.&lt;/p&gt;

&lt;p&gt;An AI that sees all hands may always find a legal escape from a bad situation. A real player-facing AI only knows its own cards and public information.&lt;/p&gt;

&lt;p&gt;I prefer interfaces shaped like this:&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="nf"&gt;chooseCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;hand&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;hands&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;currentTrick&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;currentTrick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;completedTricks&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;completedTricks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;publicBids&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;bids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;trump&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;trump&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 test driver should use the same information boundary as the game. This does not secure browser memory from a determined user. It prevents the implementation from becoming accidentally omniscient.&lt;/p&gt;

&lt;p&gt;It also makes failures more honest. If the computer cannot choose a legal card from the information it is allowed to receive, either the AI interface or the engine state needs attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Simulations found different bugs in different games
&lt;/h2&gt;

&lt;p&gt;The value of this approach became clearer as I added games with different structures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spades&lt;/strong&gt; exposed tied end conditions. Reaching the target score is not enough when both teams are tied; the match must continue until one leads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hearts&lt;/strong&gt; exposed cleanup errors at the final trick and score conservation problems around shooting the moon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Euchre&lt;/strong&gt; exposed active-seat bugs when a player went alone. A seat still existed at the table but no longer owned turns in that hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bridge&lt;/strong&gt; exposed control problems around Dummy. The seat holding the cards and the player choosing the card are not always the same person.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Oh Hell&lt;/strong&gt; exposed round-progression assumptions because the hand size changes every round and the dealer cannot always choose every numeric bid.&lt;/p&gt;

&lt;p&gt;None of these needed a browser to reproduce. The browser showed the symptom; the engine simulation reduced it to a repeatable state transition.&lt;/p&gt;

&lt;h2&gt;
  
  
  What full-game simulation does not test
&lt;/h2&gt;

&lt;p&gt;Engine simulations do not replace browser tests.&lt;/p&gt;

&lt;p&gt;They will not tell me that a mobile hand overlaps the trick area, that a dialog covers the player's cards, or that a moving card changes its face halfway through an animation. I still need visual checks and a small number of end-to-end interactions for those problems.&lt;/p&gt;

&lt;p&gt;I use three layers now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Small rule tests explain a specific rule and fail precisely.&lt;/li&gt;
&lt;li&gt;Full-match simulations prove that legal actions can carry the engine to a valid ending.&lt;/li&gt;
&lt;li&gt;Browser checks cover layout, input wiring, and animation behavior.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The middle layer was the one I was missing. It is fast enough to run with every change and broad enough to catch disagreements between individually correct rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with one ugly simulation
&lt;/h2&gt;

&lt;p&gt;You do not need property-testing software or a brilliant AI to begin.&lt;/p&gt;

&lt;p&gt;Pick the simplest legal action at every turn. Add a move budget. Assert that the match ends. Then add one invariant whenever a bug teaches you what must always remain true.&lt;/p&gt;

&lt;p&gt;My first simulations were long &lt;code&gt;while&lt;/code&gt; loops inside test files. Some still are. They are not the prettiest code in the project, but they answer the question I actually care about:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can this game finish, repeatedly, without losing track of its own rules?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A passing unit test can tell me that one move was correct. A completed simulated match tells me that the moves still agree with one another hundreds of transitions later.&lt;/p&gt;

&lt;p&gt;That distinction has saved me more debugging time than any individual test helper.&lt;/p&gt;

&lt;p&gt;The five games mentioned here are playable at &lt;a href="https://trickcardgames.com/" rel="noopener noreferrer"&gt;Trick Card Games&lt;/a&gt;. The engines are still evolving, but every new rule now has to survive more than a carefully chosen example. It has to survive the whole game.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>testing</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What Building Four Browser Card Games Taught Me About State</title>
      <dc:creator>GameHzLab</dc:creator>
      <pubDate>Thu, 22 Oct 2026 12:02:44 +0000</pubDate>
      <link>https://dev.to/gamehzlab/what-building-four-browser-card-games-taught-me-about-state-4eig</link>
      <guid>https://dev.to/gamehzlab/what-building-four-browser-card-games-taught-me-about-state-4eig</guid>
      <description>&lt;p&gt;I started with Spades. The rules engine worked, the computer players could finish a hand, and the score came out correctly. Then I watched an opponent play a card and saw its face change halfway across the table.&lt;/p&gt;

&lt;p&gt;Hearts found a different failure: the last trick of a round appeared twice. Euchre let a player go alone, but the sitting-out partner still looked active. Bridge could reach a valid contract and then stall because the human player was supposed to control Dummy rather than the seat at the bottom of the screen.&lt;/p&gt;

&lt;p&gt;None of those bugs came from misunderstanding the card rules. They came from the boundary between game state and what the player sees.&lt;/p&gt;

&lt;p&gt;I now have four playable trick-taking games in the browser: Spades, Hearts, Euchre, and Contract Bridge. They share cards, a table, menus, themes, and animation ideas, but they do not share the same game loop. Building them changed how I think about state in small games.&lt;/p&gt;

&lt;p&gt;You can see the current result at &lt;a href="https://trickcardgames.com/" rel="noopener noreferrer"&gt;Trick Card Games&lt;/a&gt;. This is what I learned while getting there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Share the table, not the rules
&lt;/h2&gt;

&lt;p&gt;The first temptation was to make one large &lt;code&gt;CardGame&lt;/code&gt; abstraction. Four players sit around a table, everyone follows suit, somebody wins a trick, and scores are updated. It looked like an obvious inheritance tree.&lt;/p&gt;

&lt;p&gt;It was not.&lt;/p&gt;

&lt;p&gt;Spades has a bidding phase and a trump suit that must be broken. Hearts passes three cards before most rounds and scores penalty cards rather than contracts. Euchre uses 24 cards, changes the effective suit of the left bower, and may remove one partner from a lone hand. Bridge has an auction, an opening lead, a visible Dummy hand, vulnerability, and a declarer who controls two seats.&lt;/p&gt;

&lt;p&gt;The useful shared layer turned out to be much smaller:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;card dimensions and face rendering&lt;/li&gt;
&lt;li&gt;hand fans for the four seats&lt;/li&gt;
&lt;li&gt;dealing and trick-collection animation&lt;/li&gt;
&lt;li&gt;table themes and card backs&lt;/li&gt;
&lt;li&gt;menu, full-screen, sound, and speed preferences&lt;/li&gt;
&lt;li&gt;common layout rules for desktop and mobile&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rules engines stay separate. That duplication is cheaper than an abstraction full of flags such as &lt;code&gt;hasPassing&lt;/code&gt;, &lt;code&gt;hasDummy&lt;/code&gt;, &lt;code&gt;canGoAlone&lt;/code&gt;, and &lt;code&gt;trumpMustBeBroken&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The rule I use now is simple: share presentation when the player expects consistency; keep domain state separate when the games use different language.&lt;/p&gt;

&lt;h2&gt;
  
  
  The engine owns the truth
&lt;/h2&gt;

&lt;p&gt;My UI used to answer rule questions because the answer felt easy. Can this card be played? Is the hand over? Whose turn is next? That worked until one screen and one engine answered the same question differently.&lt;/p&gt;

&lt;p&gt;Now the UI sends an action and waits for a result. It does not calculate legality on its own.&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;type&lt;/span&gt; &lt;span class="nx"&gt;PlayAction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Seat&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Card&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PlayResult&lt;/span&gt; &lt;span class="o"&gt;=&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;GameState&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;playedCard&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Card&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;trickComplete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;roundComplete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&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;playCard&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;GameState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PlayAction&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;PlayResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Validate turn, ownership, and follow-suit rules here.&lt;/span&gt;
  &lt;span class="c1"&gt;// Return a new state plus the facts the UI needs to animate.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters even in a single-player browser game. Buttons, keyboard input, computer turns, and animation callbacks can all try to advance the same hand. There should be one place that is allowed to say what happened.&lt;/p&gt;

&lt;p&gt;It also made testing easier. The engine does not need the DOM, timers, CSS transitions, or a browser. A test can play an entire match as fast as the CPU allows.&lt;/p&gt;

&lt;h2&gt;
  
  
  An animation needs its own snapshot
&lt;/h2&gt;

&lt;p&gt;The card-face bug in Spades was caused by reusing a DOM node while the underlying hand changed. The animation began with one card, the next render reused the element, and the face changed before the movement finished.&lt;/p&gt;

&lt;p&gt;The fix was not a longer delay. The moving card needed an immutable snapshot.&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;playCard&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;action&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;animationFrame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;card&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playedCard&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getSeatPosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getTrickPosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nf"&gt;patchVisibleHand&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="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seat&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;animateCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;animationFrame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&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="nf"&gt;renderTrick&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;currentTrick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The animation reads &lt;code&gt;animationFrame.card&lt;/code&gt;, not "whatever card is currently at index 7." The hand can shrink without changing the object that is moving.&lt;/p&gt;

&lt;p&gt;This also stopped me from rerendering every hand after every play. Opponent card backs should lose one card; they do not need to disappear and rebuild. A small DOM patch looks steadier and is much easier to follow on a phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  A completed trick is a transaction
&lt;/h2&gt;

&lt;p&gt;The Hearts round bug was more subtle. The fourth card was played, the engine marked the round complete, and two pieces of UI code both tried to finish the trick. One collected the cards. The other rendered the "final" trick again before opening the score summary.&lt;/p&gt;

&lt;p&gt;I fixed that by treating trick completion as one transaction:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;lock player input&lt;/li&gt;
&lt;li&gt;place the fourth card&lt;/li&gt;
&lt;li&gt;leave all four cards visible long enough to read&lt;/li&gt;
&lt;li&gt;record the winner exactly once&lt;/li&gt;
&lt;li&gt;animate the cards into the winner's pile&lt;/li&gt;
&lt;li&gt;clear the trick surface&lt;/li&gt;
&lt;li&gt;either start the next trick or open the round summary&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is only one owner for that sequence. A collection guard prevents another render path from entering it, and a transition token makes old timers harmless after a new game starts.&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isCollectingTrick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;isCollectingTrick&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;try&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;showCompletedTrick&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;collectTrick&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;advanceAfterTrick&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;isCollectingTrick&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not the boolean. It is deciding that "fourth card played" and "ready for the next action" are different moments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn ownership is not the same as seat ownership
&lt;/h2&gt;

&lt;p&gt;Bridge made this impossible to ignore.&lt;/p&gt;

&lt;p&gt;The human normally sits South. If South becomes declarer, the human also plays North's exposed Dummy cards. If North becomes declarer, South may be Dummy, and the computer declarer must choose cards from the human's visible hand.&lt;/p&gt;

&lt;p&gt;A check like this is wrong:&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;humanCanPlay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentTurnSeat&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;south&lt;/span&gt;&lt;span class="dl"&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 real question is who controls the seat for this contract:&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;function&lt;/span&gt; &lt;span class="nf"&gt;isHumanControlledSeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Seat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Contract&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;seat&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;south&lt;/span&gt;&lt;span class="dl"&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;seat&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;south&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dummy&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;south&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;seat&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dummy&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;declarer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;south&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Euchre has the same distinction in a smaller form. When somebody goes alone, the partner still owns a seat at the table, but does not own a turn in that hand.&lt;/p&gt;

&lt;p&gt;Once control became an explicit function, the UI prompts, legal-card highlighting, and computer loop all used the same answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Computer players should not receive hidden cards
&lt;/h2&gt;

&lt;p&gt;A browser has all four hands in memory, so it is easy to write a computer player that accidentally cheats. Passing the whole &lt;code&gt;GameState&lt;/code&gt; into &lt;code&gt;chooseCard()&lt;/code&gt; is convenient, but it also gives the function information a real player would not have.&lt;/p&gt;

&lt;p&gt;The safer interface accepts only what that seat is allowed to know:&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="nf"&gt;chooseCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;hand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hands&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;currentSeat&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;currentTrick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;completedTricks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;publicBids&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 function cannot inspect an opponent's hand because it never receives one. This is not security in the usual sense; a determined player can inspect browser memory. It is protection against my own implementation mistakes and a way to keep the computer's decisions honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full-game simulations catch more than unit examples
&lt;/h2&gt;

&lt;p&gt;Rule-level tests are still valuable. I test that the left bower belongs to trump in Euchre, that Hearts has exactly 26 penalty points, and that Bridge scoring handles vulnerable and doubled contracts.&lt;/p&gt;

&lt;p&gt;But the tests that have saved the most time run complete games.&lt;/p&gt;

&lt;p&gt;They check invariants such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every deal contains the expected number of unique cards&lt;/li&gt;
&lt;li&gt;no player breaks follow-suit rules&lt;/li&gt;
&lt;li&gt;each trick removes one card from every active hand&lt;/li&gt;
&lt;li&gt;the round reaches scoring rather than stalling&lt;/li&gt;
&lt;li&gt;the dealer and vulnerability rotate correctly&lt;/li&gt;
&lt;li&gt;a tied end condition continues when the rules require it&lt;/li&gt;
&lt;li&gt;the next round starts with an empty trick surface&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simulation does not care whether the computer plays brilliant cards. It cares that hundreds of legal decisions eventually reach a valid result. That is exactly where turn-loop bugs tend to hide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Static pages and interactive tables can coexist
&lt;/h2&gt;

&lt;p&gt;The site uses Astro to generate the surrounding pages and TypeScript clients for the tables. The rules, scoring explanations, FAQs, and links arrive as HTML. The game code loads only where a table needs it.&lt;/p&gt;

&lt;p&gt;That split has been useful for reasons beyond performance. A playable game needs fast input and local state. A rules page needs stable URLs, readable headings, internal links, and content that still exists before JavaScript starts.&lt;/p&gt;

&lt;p&gt;Putting the table and its guide on the same page also gives players somewhere to check a rule without leaving the game entirely. The product and the documentation can share a URL without sharing an execution model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;If I started again, I would establish three boundaries before drawing the first card:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The engine is the only owner of legal state transitions.&lt;/li&gt;
&lt;li&gt;Animations consume snapshots and never infer card identity from a changing array index.&lt;/li&gt;
&lt;li&gt;Each async transition has one owner, one lock, and a clear cancellation path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I would still build separate engines for the four games. Their differences are the interesting part. The common table should make them feel like one product, but it should not pretend their rules are the same.&lt;/p&gt;

&lt;p&gt;Card games look small because the board fits on one screen. Their state is not small. Hidden information, strict turn order, delayed animation, partnerships, and round transitions create plenty of room for two individually correct systems to disagree.&lt;/p&gt;

&lt;p&gt;Most of my hardest bugs lived in that disagreement. Making the boundary explicit did more for the games than adding another clever computer strategy ever could.&lt;/p&gt;

&lt;p&gt;The four games are playable at &lt;a href="https://trickcardgames.com/" rel="noopener noreferrer"&gt;Trick Card Games&lt;/a&gt;. I am still refining the engines and the table, but the state model is finally carrying more of the weight than the UI timers.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>gamedev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Maximizing Internal Link Equity</title>
      <dc:creator>GameHzLab</dc:creator>
      <pubDate>Mon, 08 Jun 2026 13:35:14 +0000</pubDate>
      <link>https://dev.to/gamehzlab/maximizing-internal-link-equity-3ijb</link>
      <guid>https://dev.to/gamehzlab/maximizing-internal-link-equity-3ijb</guid>
      <description>&lt;p&gt;To ensure search engine crawlers discover our updated routes, we overhauled our linking structure:&lt;/p&gt;

&lt;p&gt;Dynamic Sitemap integration: Our dynamic sitemap.ts now automatically queries and appends all category routes, assuring 100% indexability.&lt;br&gt;
Footer &amp;amp; Header Linking: Added trending categories directly to the Footer to pass down PageRank and link equity to deep subpages.&lt;br&gt;
Localized Image SEO: Updated rendering components (like MahjongCard) to include dynamic, localized alt tags to capture traffic from image search engines.&lt;br&gt;
Virtual Category Routes: Moved virtual groupings like "Recently Played" and "Hot" from memory-based state hooks to indexable URL-driven pathnames.&lt;br&gt;
Key Takeaways&lt;br&gt;
By shifting from a basic, single-relationship setup to a robust, search-friendly architecture:&lt;/p&gt;

&lt;p&gt;🚀 Database queries are 10x faster thanks to slug-based database indexing.&lt;br&gt;
🤖 CMS content velocity increased by 99% by building an LLM-assisted schema generation script.&lt;br&gt;
📈 Search crawlability was maximized through dynamic metadata, localized canonical alternates, and automated JSON-LD schemas.&lt;br&gt;
If you are building a directory-based web application or content portal, investing early in structured database columns, dynamic schemas (JSON-LD), and automated AI enrichment pipelines will pay massive dividends down the road.&lt;/p&gt;

&lt;p&gt;If you are building a directory-based web application or content portal, investing early in structured database columns, dynamic schemas (JSON-LD), and automated AI enrichment pipelines will pay massive dividends down the road.&lt;/p&gt;

&lt;p&gt;What does your Next.js SEO stack look like? Let us know in the comments below!&lt;/p&gt;

</description>
      <category>nimigame</category>
      <category>webdev</category>
      <category>gamehzlab</category>
      <category>freegame</category>
    </item>
    <item>
      <title>🚀 From HTTP Loopbacks to Direct Service Layer: How We Fixed 403 Errors and Boosted Performance</title>
      <dc:creator>GameHzLab</dc:creator>
      <pubDate>Thu, 26 Mar 2026 05:45:24 +0000</pubDate>
      <link>https://dev.to/gamehzlab/from-http-loopbacks-to-direct-service-layer-how-we-fixed-403-errors-and-boosted-performance-3563</link>
      <guid>https://dev.to/gamehzlab/from-http-loopbacks-to-direct-service-layer-how-we-fixed-403-errors-and-boosted-performance-3563</guid>
      <description>&lt;p&gt;We just hit a major architectural milestone at &lt;a href="//www.gamehzlab.com"&gt;GameHzLab&lt;/a&gt;! 🚀&lt;/p&gt;

&lt;p&gt;After weeks of refactoring, we have successfully migrated our project's backend from a "Loopback-heavy" architecture to a Direct Service Layer pattern. If you’ve ever struggled with your own WAF blocking your server-side requests, this one is for you.&lt;/p&gt;

&lt;p&gt;🛑 The Problem: The "403 Forbidden" Wall&lt;br&gt;
Previously, our Server Components (Home, Category, and Detail pages) on GameHzLab fetched data by making internal HTTP requests to our own API routes. While this felt "modular," it introduced a critical point of failure: Cloudflare WAF.&lt;/p&gt;

&lt;p&gt;Our server's internal requests were occasionally flagged as suspicious by our own firewall, leading to intermittent 403 errors and broken page renders.&lt;/p&gt;

&lt;p&gt;🛠️ The Solution: gameService.ts&lt;br&gt;
We introduced a unified gameService.ts to act as our data "Source of Truth."&lt;/p&gt;

&lt;p&gt;Direct Database Access: Server components now call the service layer directly to fetch data from Supabase.&lt;/p&gt;

&lt;p&gt;Bypassing the WAF: Since these are internal function calls rather than outbound HTTP requests, we have completely eliminated the risk of being blocked by our own security layers.&lt;/p&gt;

&lt;p&gt;Architecture Standardization: All API routes have been refactored into lightweight wrappers around gameService, making the codebase cleaner and significantly easier to maintain.&lt;/p&gt;

&lt;p&gt;📈 The Results&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Major Performance Boost (TTFB)
By cutting out the overhead of the internal network stack (DNS lookup, TCP handshake, and TLS negotiation for every internal request), we’ve slashed our Time to First Byte (TTFB).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Estimated Impact: We expect a 100-200ms reduction in page rendering latency across the platform.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;GA4 Data Purity 🛡️
Analytics are only useful if they are accurate. We noticed that server-side crawlers were inflating our numbers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We’ve implemented a server-side bot detection logic in layout.tsx. Now, when known crawlers visit GameHzLab, the GA4 script is no longer loaded. This protects our analysis from "noise" and ensures our data reflects real human engagement.&lt;/p&gt;

&lt;p&gt;💡 Key Takeaway&lt;br&gt;
If you are building with Next.js or any modern SSR framework, avoid the temptation to "fetch your own API" from within Server Components. It adds latency, introduces security hurdles, and complicates your architecture. Go direct.&lt;/p&gt;

&lt;p&gt;Check out the live results at: &lt;a href="http://www.gamehzlab.com" rel="noopener noreferrer"&gt;www.gamehzlab.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Have you faced issues with WAFs blocking your own server-side requests? Let’s discuss in the comments!&lt;br&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%2Fzv97f4de3in2jba5s4tn.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%2Fzv97f4de3in2jba5s4tn.png" alt=" " width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  webdev #nextjs #architecture #performance #supabase #refactoring
&lt;/h1&gt;

</description>
    </item>
    <item>
      <title>How I Saved My Serverless Game Platform from Bot Traffic using Cloudflare WAF</title>
      <dc:creator>GameHzLab</dc:creator>
      <pubDate>Mon, 23 Mar 2026 06:04:01 +0000</pubDate>
      <link>https://dev.to/gamehzlab/how-i-saved-my-serverless-game-platform-from-bot-traffic-using-cloudflare-waf-1b14</link>
      <guid>https://dev.to/gamehzlab/how-i-saved-my-serverless-game-platform-from-bot-traffic-using-cloudflare-waf-1b14</guid>
      <description>&lt;p&gt;Tags: #webdev #security #serverless #nextjs #cloudflare&lt;/p&gt;

&lt;p&gt;The Problem: 0% Engagement and 5xx Errors&lt;br&gt;
Recently, while developing my H5 game platform &lt;a href="https://dev.tourl"&gt;gamehzlab.com&lt;/a&gt;, I noticed a disturbing trend in GA4: 100% bounce rate and 0.0% engagement time.&lt;/p&gt;

&lt;p&gt;Despite having nearly 2k unique visitors, the "users" were spending only 2 seconds on the site. Even worse, Google Search Console started reporting 5xx Server Errors, preventing my games from being indexed.&lt;/p&gt;

&lt;p&gt;The Trap: Global Bot Fight Mode&lt;br&gt;
My first instinct was to toggle Cloudflare's "Bot Fight Mode".&lt;br&gt;
Result: It backfired.&lt;br&gt;
While it stopped some bots, it also blocked my game's .wasm and .data files. The AI-based protection was too aggressive, treating large binary fetches as suspicious activity, effectively breaking the game for real players.&lt;/p&gt;

&lt;p&gt;The Solution: Smart Tiered Defense&lt;br&gt;
I shifted from "Global Blocking" to a Custom WAF Rule strategy. Here is the logic that finally balanced security and accessibility:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Targeted Challenges&lt;br&gt;
Instead of challenging everyone, I targeted high-risk, low-engagement regions (e.g., specific data center hubs) and excluded verified crawlers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The "Verified Bot" Green Channel&lt;br&gt;
I used the Known Bots field to ensure Googlebot and Bingbot could always reach the site, even when my manual rules were strict.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Protecting the Entry Point Only&lt;br&gt;
By restricting the challenge to the root path (/), I ensured that once a human passed the initial check, their browser could freely download game assets without further interruption.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Current Status: The Road to Indexing&lt;br&gt;
After deploying these changes and validating the fix in GSC, 129 pages are now in the "Discovered - currently not indexed" queue with a status of "Started". This means Google has acknowledged the server is healthy again and is re-queuing the content for crawl.&lt;/p&gt;

&lt;p&gt;Key Takeaways for Indie Devs:&lt;br&gt;
Don't trust "Bot Fight Mode" blindly for asset-heavy sites (Wasm/H5 games).&lt;/p&gt;

&lt;p&gt;Prioritize Cache Rate: My initial cache rate was only 1.33%. Boosting this via Cache Rules is the best way to shield your Vercel/Supabase origin.&lt;/p&gt;

&lt;p&gt;Watch GA4 Engagement: If it’s 0%, you don't have a traffic problem; you have a bot problem.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>From 280MB to 1MB: How I Optimized Performance and SEO for My H5 Gaming Platform</title>
      <dc:creator>GameHzLab</dc:creator>
      <pubDate>Thu, 19 Mar 2026 09:14:59 +0000</pubDate>
      <link>https://dev.to/gamehzlab/from-280mb-to-1mb-how-i-optimized-performance-and-seo-for-my-h5-gaming-platform-4pgl</link>
      <guid>https://dev.to/gamehzlab/from-280mb-to-1mb-how-i-optimized-performance-and-seo-for-my-h5-gaming-platform-4pgl</guid>
      <description>&lt;p&gt;Building a global H5 gaming platform like &lt;strong&gt;GameHzLab&lt;/strong&gt; is a race against both loading times and search engine indexing. When I discovered my initial API payloads were hitting 280MB and search engine visibility was stagnant, I knew a deep architectural overhaul was required.&lt;/p&gt;

&lt;p&gt;Through a combination of extreme asset compression and a V2 SEO strategy, I managed to slash payloads by over 99% and achieve an &lt;strong&gt;866.7% growth in organic search traffic&lt;/strong&gt; within weeks. Here is the technical breakdown of that journey.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The Performance Revolution: Slashing Payloads&lt;/strong&gt;&lt;br&gt;
In web gaming, every second of loading time correlates directly to user drop-off. My primary goal was to ensure the "instant play" promise of H5 games.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;API Optimization: By refactoring backend logic and utilizing Serverless Edge Functions, I successfully reduced the primary API response from 280MB to under 1MB. This ensures a smooth experience even for players on unstable mobile networks.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Engine &amp;amp; Texture Compression: For games built on Cocos and Unity, I moved away from standard PNGs to WebP and ASTC formats. I also implemented engine stripping to remove unused modules (like 3D physics for 2D games), ensuring every byte downloaded contributes to the immediate gameplay.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. SEO V2: Category Hubs as Traffic Magnets&lt;/strong&gt;&lt;br&gt;
To help search engines better understand the site's hierarchy, I upgraded the classification system from simple query parameters to a robust link structure.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Route Rewriting: I shortened category paths from /category/ to a more concise and authoritative /c/.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Dynamic Routing: I implemented dynamic /[locale]/c/[id] routes for every supported language. This created stable "landing hubs" for high-volume categories like Mahjong or Puzzle games.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Structured Data (JSON-LD): By adding Breadcrumbs and CollectionPage schemas, I explicitly informed Google about the site's structure, leading to better rich-snippet representation in search results.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Content Enrichment: The "Game DNA" Factor&lt;/strong&gt;&lt;br&gt;
SEO is as much about content quality as it is about code. I expanded the content for all 15 core categories to increase authority.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Deep Descriptions: Each category page now features unique descriptions optimized for long-tail keywords.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Game DNA System: I developed a system that maps player psychology to gameplay. This interactive element has significantly increased "dwell time"—I've even observed individual US-based players staying engaged for over 36 minutes.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;4. Real-World Impact: By the Numbers&lt;/strong&gt;&lt;br&gt;
The data from Google Search Console and GA4 confirms the success of these optimizations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Search Visibility: The site achieved an average ranking of 16.4 with a healthy 4.5% click-through rate (CTR).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Traffic Explosion: Organic search traffic grew by 866.7%.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Global Reach: While I initially saw a spike in 4-second "bot-like" traffic from Singapore, the platform has successfully attracted high-value "real" players from the United States with engagement times exceeding 30 minutes.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;br&gt;
For a solo developer, &lt;strong&gt;extreme performance&lt;/strong&gt; is the baseline for retention, and &lt;strong&gt;structured SEO&lt;/strong&gt; is the lifeline for growth. By leveraging Next.js and a lean asset strategy, GameHzLab demonstrates that a single engineer can build a platform capable of competing on a global scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explore the results at&lt;/strong&gt; &lt;a href="https://dev.tourl"&gt;GameHzLab.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>gamedev</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
