<?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: Marvin Tang</title>
    <description>The latest articles on DEV Community by Marvin Tang (@imagebear).</description>
    <link>https://dev.to/imagebear</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3853508%2F6263f42d-cafc-4320-8ddd-44b7d2c7c9eb.png</url>
      <title>DEV Community: Marvin Tang</title>
      <link>https://dev.to/imagebear</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/imagebear"/>
    <language>en</language>
    <item>
      <title>Mapping TikTok's 46 Hidden Emoji Codes: A Reverse Engineering Story</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Fri, 08 May 2026 09:04:05 +0000</pubDate>
      <link>https://dev.to/imagebear/mapping-tiktoks-46-hidden-emoji-codes-a-reverse-engineering-story-16p8</link>
      <guid>https://dev.to/imagebear/mapping-tiktoks-46-hidden-emoji-codes-a-reverse-engineering-story-16p8</guid>
      <description>&lt;p&gt;A while back I noticed something in a TikTok comment thread that didn't make sense to me. People were typing what looked like emojis I'd never seen in any standard keyboard. A shaking face. A specific cartoon thumbs-up that didn't match the iOS or Android version. A speechless head-tilt.&lt;/p&gt;

&lt;p&gt;At first I assumed they were stickers, or some creator-only feature. But the more I scrolled, the more I noticed regular accounts using them in plain text comments. They were typing something. The platform was rendering it as a custom emoji.&lt;/p&gt;

&lt;p&gt;What I found over the next two weeks turned into &lt;a href="https://ttemos.com/" rel="noopener noreferrer"&gt;TTEmos&lt;/a&gt; — a reference site for TikTok's 46 undocumented hidden emoji codes. This is the technical version of how that worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first lead
&lt;/h2&gt;

&lt;p&gt;The first thing I did was right-click "Inspect" on a comment that contained one of these mystery emojis. The DOM showed an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag pointing to an asset on TikTok's CDN. Nothing unusual there, except that the user had clearly typed plain text into the comment box — the input field had no emoji picker open, and there was no sticker UI involved.&lt;/p&gt;

&lt;p&gt;That meant somewhere between the user typing and the comment rendering, TikTok was doing a text replacement.&lt;/p&gt;

&lt;p&gt;I went to the DevTools Network tab, posted a test comment with the text &lt;code&gt;[smile]&lt;/code&gt;, and watched. Two things happened:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The submit request sent the literal string &lt;code&gt;[smile]&lt;/code&gt; to the API, with no client-side replacement.&lt;/li&gt;
&lt;li&gt;When the comment came back from the server and rendered, the text was gone and an image was in its place.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the replacement was happening on render, against a known list of codes the platform recognized.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the list
&lt;/h2&gt;

&lt;p&gt;The next step was figuring out where this known list lived. The likely candidates were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A JS bundle on the web client that did the replacement after fetch&lt;/li&gt;
&lt;li&gt;Server-rendered HTML that arrived with the image already in place&lt;/li&gt;
&lt;li&gt;A separate config endpoint the client fetched on load&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I spent about an hour going through bundled JS files in the Sources tab, looking for anything that mapped a square-bracket-wrapped string to an image filename. After narrowing it down, I found a chunk of code with the structure I was looking for. Roughly (paraphrased — not the literal source):&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;EMOJI_MAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[smile]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/emoji/smile.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[happy]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/emoji/happy.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[shock]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/emoji/shock.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&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;renderCommentText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\[\w&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\]&lt;/span&gt;&lt;span class="sr"&gt;/g&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="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;asset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;EMOJI_MAP&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;asset&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;img src="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&amp;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="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;Once I had the map, I had the codes. There were 46 of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation
&lt;/h2&gt;

&lt;p&gt;Having a list extracted from a JS file is a hypothesis, not a confirmed reference. The list could include codes that no longer worked. It could exclude codes that lived in a different code path. I needed to validate every entry on the live platform.&lt;/p&gt;

&lt;p&gt;So I made a test account and started typing each code into comments, one at a time. For each code I checked:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does the code render as an emoji at all?&lt;/li&gt;
&lt;li&gt;Is the rendered emoji visually distinct from the others?&lt;/li&gt;
&lt;li&gt;Does it render the same way in different contexts (comment, caption, reply)?&lt;/li&gt;
&lt;li&gt;Does it survive the &lt;code&gt;Edit&lt;/code&gt; flow without breaking?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most codes passed cleanly. A few were interesting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Three codes were effectively deprecated — they were in the JS map but rendered as literal text on the live platform.&lt;/li&gt;
&lt;li&gt;Two codes had visually identical outputs (they pointed to the same asset).&lt;/li&gt;
&lt;li&gt;Some codes had region-specific rendering — they worked normally in some locales but not others.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I documented each of these on the reference site as separate metadata fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reference site itself
&lt;/h2&gt;

&lt;p&gt;Once I had the validated list, the question became how to make it useful.&lt;/p&gt;

&lt;p&gt;The temptation with this kind of niche knowledge is to write a long blog post listing everything. That's how most fragments of this list had been published before — buried in Reddit threads, halfway through YouTube tutorials, scattered across screenshots in TikTok comments themselves. The problem is that a blog post is something you read once. People who actually wanted to use these codes needed something they'd come back to dozens of times.&lt;/p&gt;

&lt;p&gt;So I built a static site. No backend. No database. One page with all 46 emojis, a search/filter, and a one-click copy button on each one.&lt;/p&gt;

&lt;p&gt;The architecture was deliberately boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ttemos.com/
├── index.html        # main reference page
├── emojis.json       # the validated 46 entries
└── assets/
    └── *.png         # locally hosted emoji images
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A small bit of JavaScript handles search, filter, and copy-to-clipboard:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;copyCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;)&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="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;button&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;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;copied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;button&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;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;copied&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// fall back to a hidden textarea + execCommand for older browsers&lt;/span&gt;
    &lt;span class="nf"&gt;fallbackCopy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&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's the whole product. The user opens the site, finds the emoji they want, clicks copy, pastes into a TikTok comment. Three seconds.&lt;/p&gt;

&lt;p&gt;I hosted my own copies of the emoji-style icons rather than hot-linking to TikTok's CDN. Hot-linking would have been faster to set up but fragile — TikTok could change their CDN paths at any time and the reference site would break overnight without me even noticing.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on undocumented features
&lt;/h2&gt;

&lt;p&gt;There's an ethics question hovering over this kind of project worth addressing directly.&lt;/p&gt;

&lt;p&gt;Documenting an undocumented but publicly visible feature isn't reverse-engineering an API in the harmful sense. The codes are publicly visible in the product. Users are typing them in plain text. The codes don't bypass any security boundary, don't expose private data, and don't unlock anything that wasn't already accessible.&lt;/p&gt;

&lt;p&gt;What I tried to avoid was anything that &lt;em&gt;would&lt;/em&gt; cross a line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I didn't rehost TikTok's actual emoji image assets at scale. The reference site uses my own icons in a similar style.&lt;/li&gt;
&lt;li&gt;I didn't build automation that posts to TikTok programmatically.&lt;/li&gt;
&lt;li&gt;I didn't document anything else that came up while looking for the emoji map — internal API endpoints, auth flows, anything in that category stayed in DevTools and out of the article.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal was a reference. Nothing more.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Two things, looking back:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build the validation harness first.&lt;/strong&gt; I validated codes one by one manually, which took hours. If I were starting again, I'd write a small test script that takes a list of candidate codes, posts each one to a throwaway test account, and reports back which render and which don't. The platform makes that easy because the rendered output is in the DOM after submission.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Track the source of each code.&lt;/strong&gt; Some codes came from the original JS map, some came from community Reddit threads I cross-referenced, and a few I discovered by trying patterns that weren't in either source. I didn't track which was which. When the platform updated their map a few months later and three codes broke, I had no record of which sources had been most reliable historically.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's there now
&lt;/h2&gt;

&lt;p&gt;TTEmos currently has all 46 validated codes with metadata about regional differences and deprecation status. The full reference is at &lt;a href="https://ttemos.com/" rel="noopener noreferrer"&gt;ttemos.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It's a small static site. It will probably never grow into anything bigger. But every time someone wants to type a hidden TikTok emoji, the site is there.&lt;/p&gt;

&lt;p&gt;The broader point — for anyone building something similar — is that platforms quietly accumulate undocumented features, and there's real value in being the person who writes them down. Not for the SEO. Not for affiliate clicks. Just because the documentation should exist and nobody else is making it.&lt;/p&gt;

&lt;p&gt;What undocumented features have you mapped? Drop a comment.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building 24 Random Tools in One App: An Architecture Story</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Fri, 08 May 2026 08:53:12 +0000</pubDate>
      <link>https://dev.to/imagebear/building-24-random-tools-in-one-app-an-architecture-story-4agm</link>
      <guid>https://dev.to/imagebear/building-24-random-tools-in-one-app-an-architecture-story-4agm</guid>
      <description>&lt;p&gt;When I started &lt;a href="https://randtap.com/" rel="noopener noreferrer"&gt;RandTap&lt;/a&gt;, I had a list of about eight tools I wanted to build. A dice roller. A coin flip. A password generator. A random number generator. A few others. Each one was a single-purpose utility I'd looked for separately on the web and never found a clean version of.&lt;/p&gt;

&lt;p&gt;Eight became twelve. Twelve became eighteen. By the time I shipped, the app had 24 tools, with more queued. And along the way, the architecture question stopped being "how do I build a dice roller" and started being "how do I build a system that can hold 24 unrelated tools without collapsing into spaghetti."&lt;/p&gt;

&lt;p&gt;This post is about what I learned. If you're building any kind of multi-tool app — a calculator collection, a converter suite, a generator hub — the patterns here might save you some time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one app, not 24
&lt;/h2&gt;

&lt;p&gt;The first decision was strategic, not technical. I could have shipped 24 separate single-purpose web pages. SEO-wise, that's probably better — each page can target its own keywords without competing.&lt;/p&gt;

&lt;p&gt;But each tool was tiny. A dice roller is maybe 200 lines of code. A coin flip is 50. Building 24 separate sites would have meant 24 separate deployments, 24 sets of analytics, 24 navigation experiences for the user, and 24 places I'd need to update if I changed something cross-cutting like the theme or the sound system.&lt;/p&gt;

&lt;p&gt;Bundling won. The trade-off was accepting that no single tool gets to optimize its URL structure perfectly. The win was a coherent product that compounds — every tool I add benefits from the shared shell.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shell vs the tool
&lt;/h2&gt;

&lt;p&gt;The first architectural decision that actually mattered was separating the &lt;strong&gt;shell&lt;/strong&gt; from the &lt;strong&gt;tools&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The shell is everything that's the same regardless of which tool you're using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Navigation (sidebar, header, tool picker)&lt;/li&gt;
&lt;li&gt;Theme (dark/light mode, accent color)&lt;/li&gt;
&lt;li&gt;Settings (sound on/off, haptic feedback, history retention)&lt;/li&gt;
&lt;li&gt;Layout grid (tool content area, action buttons, result display)&lt;/li&gt;
&lt;li&gt;Cross-cutting features (copy result, share, history)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tools are the thing-specific logic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A dice roller knows about face counts and rolls&lt;/li&gt;
&lt;li&gt;A password generator knows about character classes and length&lt;/li&gt;
&lt;li&gt;An animal facts tool knows about a database of facts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once I was clear on this division, the architecture got dramatically simpler. Each tool became a self-contained module that exposed a small interface to the shell:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Tool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// 'dice-roller'&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// 'Dice Roller'&lt;/span&gt;
  &lt;span class="nl"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ToolCategory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IconName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// The tool's main UI, rendered inside the shell&lt;/span&gt;
  &lt;span class="nl"&gt;render&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="nx"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// The 'generate' action when the user taps the main button&lt;/span&gt;
  &lt;span class="nl"&gt;generate&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;ToolState&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;Result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Format a result as text for clipboard / share / history&lt;/span&gt;
  &lt;span class="nl"&gt;formatResult&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;Result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kr"&gt;string&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's it. Every tool implements that interface. The shell handles everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local state vs global state
&lt;/h2&gt;

&lt;p&gt;The second pattern that mattered was being strict about state ownership.&lt;/p&gt;

&lt;p&gt;A tool like the dice roller has its own state — what dice are selected, what the last roll was, the visual animation status. None of that needs to be visible outside the tool. Treating it as global state would have created cross-tool dependencies that don't actually exist in the user's mental model.&lt;/p&gt;

&lt;p&gt;So tool state is local. Each tool manages its own state with whatever pattern makes sense for that tool — &lt;code&gt;useState&lt;/code&gt; for simple ones, &lt;code&gt;useReducer&lt;/code&gt; for complex ones, a small state machine for animation-heavy ones.&lt;/p&gt;

&lt;p&gt;But there's a small slice of state that belongs to the shell and is visible everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Active tool ID (which tool is currently displayed)&lt;/li&gt;
&lt;li&gt;Theme (dark/light, accent color)&lt;/li&gt;
&lt;li&gt;Settings (sound, haptics, history toggle)&lt;/li&gt;
&lt;li&gt;Recent results (the cross-tool history)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's global. Tools can read it, but they can't write to most of it directly. They emit events ("a result was generated"), and the shell decides what to do with them.&lt;/p&gt;

&lt;p&gt;This split kept the codebase navigable as it grew. When I added the 18th tool, I didn't have to think about what global state might conflict. I just wrote a self-contained module that conformed to the &lt;code&gt;Tool&lt;/code&gt; interface and registered it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routing and lazy loading
&lt;/h2&gt;

&lt;p&gt;With 24 tools, bundling everything into a single JavaScript file would have made the initial page load slow. Most users open RandTap to use one or two tools — they don't need the code for the other 22.&lt;/p&gt;

&lt;p&gt;The fix was lazy loading. Each tool lives in its own module, and the shell loads them on demand:&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;TOOL_REGISTRY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Tool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dice-roller&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="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./tools/dice-roller&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;coin-flip&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="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./tools/coin-flip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;password-gen&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="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./tools/password-gen&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="c1"&gt;// ... 21 more&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadTool&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Tool&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;loader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;TOOL_REGISTRY&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="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;loader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Unknown tool: &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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kr"&gt;module&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kr"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&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 first time a user opens a tool, there's a small loading state while the chunk downloads. After that, it's cached for the session.&lt;/p&gt;

&lt;p&gt;For SEO, each tool also has a server-rendered shell with the tool's name and description in static HTML. Search engines see real content even before the JS runs. This was a cheap win that significantly improved indexing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The web-to-iOS port
&lt;/h2&gt;

&lt;p&gt;About six months in, I started thinking about an iOS version. The web app was working, but App Store distribution opens up a different audience and a different monetization model.&lt;/p&gt;

&lt;p&gt;The naive plan was to wrap the web app in a WebView and ship it. This works, technically. But it doesn't feel like an iOS app. Tap latency is wrong. Animations don't feel native. Haptics don't trigger correctly. Users can tell.&lt;/p&gt;

&lt;p&gt;The harder plan — and the one I went with — was to rewrite the UI shell natively, while keeping the tool logic largely shared.&lt;/p&gt;

&lt;p&gt;The key insight was that the shell-vs-tool division I'd already made paid off here. Each tool's logic was already isolated and platform-independent. The shell — navigation, theme, settings — was the only part that had to be rewritten for iOS.&lt;/p&gt;

&lt;p&gt;That's roughly 30% of the codebase, not 100%. A meaningful saving.&lt;/p&gt;

&lt;p&gt;In practice, the iOS version uses a native shell and bridges into the tool logic through a thin adapter layer. The dice roller's "given current state, return the next roll" logic is functionally the same on both platforms.&lt;/p&gt;

&lt;p&gt;Some adaptations were unavoidable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Animations&lt;/strong&gt; are native on iOS (UIKit / CoreAnimation), not the same web CSS animations. They take similar parameters but produce different results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Haptics&lt;/strong&gt; are a real iOS feature — iPhones have a Taptic Engine that's much richer than the limited browser vibration API. The iOS version uses much more nuanced haptic feedback than the web version can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistence&lt;/strong&gt; uses &lt;code&gt;UserDefaults&lt;/code&gt; on iOS, &lt;code&gt;localStorage&lt;/code&gt; on web. The interface I exposed to tools is the same; the implementation differs underneath.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline&lt;/strong&gt; behavior is different. The iOS version is fully offline by default; the web version assumes connectivity for some features (like sharing).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;A few things, looking back:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Define the &lt;code&gt;Tool&lt;/code&gt; interface earlier.&lt;/strong&gt; I wrote the first six tools without a shared interface, then refactored them when the seventh tool revealed the pattern. The refactor was painful. If I'd seen the pattern from tool one, the codebase would be cleaner today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build the history feature later.&lt;/strong&gt; I added cross-tool history early, before I knew what users actually wanted to do with it. Most of that code went unused. If I were starting now, I'd ship without history and add it only when users asked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat the shell as a product.&lt;/strong&gt; The shell is at least as important as any individual tool. It's what makes the whole experience coherent. I underinvested in it for the first version and paid for it later when I had to retrofit settings, theme, and accessibility properly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick the tool boundaries by user intent, not technical convenience.&lt;/strong&gt; Some of my early tools were split because they were technically separate modules in my head, even though users would have wanted them combined. (Example: I shipped a "random number" tool and a "random number range" tool as two tools. They should always have been one tool with a range toggle.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What's there now
&lt;/h2&gt;

&lt;p&gt;RandTap currently has 24+ tools across categories: dice, decisions, generators, randomizers, and a small "fun" category for things like animal facts. It's free to use on the web with no account, and the iOS version is on the App Store with full offline support.&lt;/p&gt;

&lt;p&gt;You can play with the web version at &lt;a href="https://randtap.com/" rel="noopener noreferrer"&gt;randtap.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The architecture I described isn't anything novel. It's just the same separation-of-concerns pattern applied to a niche category that doesn't usually get architectural treatment. If you're building any kind of tool collection, the shell-vs-tool split, strict state boundaries, and lazy loading are probably worth applying from day one.&lt;/p&gt;

&lt;p&gt;What multi-tool patterns have you found helpful? Drop a comment.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>architecture</category>
      <category>javascript</category>
      <category>ios</category>
    </item>
    <item>
      <title>The Infinite HTTPS Redirect Loop That Hit Me at 2am (and How X-Forwarded-Proto Saved My Site)</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Thu, 30 Apr 2026 08:28:17 +0000</pubDate>
      <link>https://dev.to/imagebear/the-infinite-https-redirect-loop-that-hit-me-at-2am-and-how-x-forwarded-proto-saved-my-site-2i1a</link>
      <guid>https://dev.to/imagebear/the-infinite-https-redirect-loop-that-hit-me-at-2am-and-how-x-forwarded-proto-saved-my-site-2i1a</guid>
      <description>&lt;p&gt;Earlier this year I migrated &lt;a href="https://phyfun.com/" rel="noopener noreferrer"&gt;phyfun.com&lt;/a&gt; from www to non-&lt;a href="http://www" rel="noopener noreferrer"&gt;www&lt;/a&gt;. On paper it's a five-minute job. In practice it took the site down for several hours, generated thousands of Search Console errors, and taught me more about SiteGround's hosting stack than I ever wanted to know.&lt;/p&gt;

&lt;p&gt;This is the war story. If you're on SiteGround, or any Nginx-in-front-of-Apache hybrid setup, and you've ever wondered why your &lt;code&gt;.htaccess&lt;/code&gt; HTTPS redirect rules don't behave the way the docs say they should — this post is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan
&lt;/h2&gt;

&lt;p&gt;phyfun.com is a browser physics-games site that's been online for years. The domain was historically configured with &lt;code&gt;www.phyfun.com&lt;/code&gt; as canonical, with the bare domain redirecting to &lt;a href="http://www" rel="noopener noreferrer"&gt;www&lt;/a&gt;. I wanted to flip that — make the bare domain canonical, redirect www to bare.&lt;/p&gt;

&lt;p&gt;The reasoning was simple. I'd been moving toward shorter, cleaner URLs across all my sites, and Search Console was showing weird canonical conflicts on a few pages. Cleaning up the canonical was on my "do this when I have an evening" list for a while.&lt;/p&gt;

&lt;p&gt;So one evening I sat down to do it. The plan was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add canonical tags pointing to non-www on all pages.&lt;/li&gt;
&lt;li&gt;Update sitemap to use non-www URLs.&lt;/li&gt;
&lt;li&gt;Add 301 redirects in &lt;code&gt;.htaccess&lt;/code&gt; from www to non-www, and from HTTP to HTTPS.&lt;/li&gt;
&lt;li&gt;Update Search Console with the non-www property as canonical.&lt;/li&gt;
&lt;li&gt;Wait, watch, sleep.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1 and 2 were trivial. Step 3 is where everything went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive redirect rule
&lt;/h2&gt;

&lt;p&gt;Here's the &lt;code&gt;.htaccess&lt;/code&gt; I started with — the kind of rule you'll find in approximately every Stack Overflow answer about Apache HTTPS redirects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;RewriteEngine&lt;/span&gt; &lt;span class="ss"&gt;On&lt;/span&gt;

&lt;span class="c"&gt;# Force HTTPS&lt;/span&gt;
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{HTTPS} &lt;span class="ss"&gt;off&lt;/span&gt;
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

&lt;span class="c"&gt;# Force non-www&lt;/span&gt;
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{HTTP_HOST} ^www\.(.+)$ [NC]
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^(.*)$ https://%1/$1 [L,R=301]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is correct on a vanilla Apache setup. It is not correct on SiteGround. I deployed it. The site went down.&lt;/p&gt;

&lt;p&gt;What I saw in the browser: &lt;code&gt;ERR_TOO_MANY_REDIRECTS&lt;/code&gt;. What I saw in the Apache logs (after I figured out where SiteGround keeps them): every single request was being 301'd back to itself, in an infinite loop.&lt;/p&gt;

&lt;p&gt;I rolled back. The site came up. I went to make coffee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this fails on SiteGround
&lt;/h2&gt;

&lt;p&gt;It took me an embarrassing amount of digging to understand what was happening. The short version:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SiteGround runs Nginx in front of Apache. Nginx terminates TLS.&lt;/strong&gt; By the time the request reaches Apache, it's already been decrypted, and Apache sees a plain HTTP request — even when the user is browsing over HTTPS.&lt;/p&gt;

&lt;p&gt;So when my &lt;code&gt;.htaccess&lt;/code&gt; rule says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{HTTPS} &lt;span class="ss"&gt;off&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apache evaluates &lt;code&gt;%{HTTPS}&lt;/code&gt; as &lt;code&gt;off&lt;/code&gt; for &lt;strong&gt;every single request&lt;/strong&gt;, including HTTPS ones, because that's what Apache sees. The rule then 301-redirects the request to HTTPS. The browser follows the redirect, hits Nginx, which terminates TLS again, hands the now-HTTP request to Apache, which sees &lt;code&gt;%{HTTPS} = off&lt;/code&gt;, redirects again, and the cycle continues until the browser gives up.&lt;/p&gt;

&lt;p&gt;This is a really common gotcha on hosts that use this kind of hybrid setup — SiteGround, certain WP Engine configurations, some Cloudways stacks, anywhere there's a TLS-terminating reverse proxy in front of Apache. If you've never run into it, count yourself lucky.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: X-Forwarded-Proto
&lt;/h2&gt;

&lt;p&gt;The fix is to stop trusting Apache's view of the protocol and instead read the header that Nginx adds when forwarding the request. That header is &lt;code&gt;X-Forwarded-Proto&lt;/code&gt;, and it contains the actual protocol the client used (&lt;code&gt;http&lt;/code&gt; or &lt;code&gt;https&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Here's the corrected rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;RewriteEngine&lt;/span&gt; &lt;span class="ss"&gt;On&lt;/span&gt;

&lt;span class="c"&gt;# Force HTTPS — using X-Forwarded-Proto for Nginx-in-front-of-Apache hosts&lt;/span&gt;
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{HTTP:X-Forwarded-Proto} !https
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

&lt;span class="c"&gt;# Force non-www&lt;/span&gt;
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{HTTP_HOST} ^www\.(.+)$ [NC]
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^(.*)$ https://%1/$1 [L,R=301]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key change is &lt;code&gt;RewriteCond %{HTTP:X-Forwarded-Proto} !https&lt;/code&gt; instead of &lt;code&gt;RewriteCond %{HTTPS} off&lt;/code&gt;. Apache's &lt;code&gt;%{HTTPS}&lt;/code&gt; is wrong on this kind of host. The header is right.&lt;/p&gt;

&lt;p&gt;A few things worth knowing about this syntax:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;%{HTTP:HeaderName}&lt;/code&gt; is how you access an arbitrary HTTP header in mod_rewrite. The &lt;code&gt;HTTP:&lt;/code&gt; prefix tells Apache to look in the request headers, not its built-in environment variables.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;!&lt;/code&gt; negates the match. So &lt;code&gt;!https&lt;/code&gt; means "if X-Forwarded-Proto is anything other than https, redirect."&lt;/li&gt;
&lt;li&gt;This works because Nginx faithfully sets &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; when forwarding. If you ever set this rule on a host where Nginx isn't doing that, the rule will silently misbehave. Confirm your host actually sets the header before relying on it. You can check with &lt;code&gt;curl -I&lt;/code&gt; or by dumping headers in a tiny PHP file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After deploying this, the site came back up. The redirect loop was gone. I made more coffee.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bonus problem: .aspx zombie URLs
&lt;/h2&gt;

&lt;p&gt;While I was already in &lt;code&gt;.htaccess&lt;/code&gt;, I decided to deal with another issue I'd been ignoring.&lt;/p&gt;

&lt;p&gt;phyfun.com had been on a different platform years ago, and that platform used &lt;code&gt;.aspx&lt;/code&gt; URLs. When I rebuilt the site on a different stack, those URLs went away — but the wider web had no way to know that. Old links to &lt;code&gt;.aspx&lt;/code&gt; pages were still being requested, by humans and bots, years later.&lt;/p&gt;

&lt;p&gt;For a long time those requests had been hitting my generic 404 page, which returned a &lt;code&gt;200 OK&lt;/code&gt; status with "page not found" content. This is what's called a &lt;strong&gt;soft 404&lt;/strong&gt; — the response body says "not found" but the HTTP status code says "OK". Google really doesn't like this. Search Console had been flagging hundreds of these for ages, which I'd been politely ignoring.&lt;/p&gt;

&lt;p&gt;The right answer for URLs that are gone and never coming back is &lt;strong&gt;HTTP 410 Gone&lt;/strong&gt;, not 404. The semantic difference matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;404 Not Found&lt;/code&gt; means "the resource isn't here right now, maybe try later."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;410 Gone&lt;/code&gt; means "this resource is permanently gone, stop asking, deindex it."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google treats them very differently. 410s drop out of the index much faster than 404s.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.htaccess&lt;/code&gt; rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="c"&gt;# Permanently mark old .aspx URLs as gone&lt;/span&gt;
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; \.aspx$ - [G,L]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;[G]&lt;/code&gt; flag returns 410 Gone. The &lt;code&gt;-&lt;/code&gt; means "don't substitute the URL." The &lt;code&gt;[L]&lt;/code&gt; stops further rewrite processing for these URLs.&lt;/p&gt;

&lt;p&gt;Within a few weeks of deploying this, Search Console's "Soft 404" report stopped growing. Within about two months, the old &lt;code&gt;.aspx&lt;/code&gt; URLs were essentially fully removed from Google's index. The Search Console error counts dropped from "hundreds, occasionally rising" to "zero."&lt;/p&gt;

&lt;p&gt;If you have a site with a long history of URL changes, run a "soft 404" audit. Anything that's permanently gone should return 410, not 404, and definitely not 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full corrected .htaccess
&lt;/h2&gt;

&lt;p&gt;For reference, here's roughly what I ended up with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;RewriteEngine&lt;/span&gt; &lt;span class="ss"&gt;On&lt;/span&gt;

&lt;span class="c"&gt;# 1. Force HTTPS (Nginx-in-front-of-Apache aware)&lt;/span&gt;
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{HTTP:X-Forwarded-Proto} !https
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

&lt;span class="c"&gt;# 2. Force non-www&lt;/span&gt;
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{HTTP_HOST} ^www\.(.+)$ [NC]
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^(.*)$ https://%1/$1 [L,R=301]

&lt;span class="c"&gt;# 3. Permanently mark old .aspx URLs as Gone&lt;/span&gt;
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; \.aspx$ - [G,L]

&lt;span class="c"&gt;# 4. Standard rewrite rules for the rest of the site below…&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the migration in three blocks. The first one is the trap I fell into. The second is straightforward. The third is the tidying-up that should have happened years earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Trust your host's actual stack, not the generic Apache docs.&lt;/strong&gt; "Nginx-in-front-of-Apache" is a different deployment from "vanilla Apache," and a lot of standard &lt;code&gt;.htaccess&lt;/code&gt; snippets are subtly wrong on it. Always check what your host actually runs before pasting Stack Overflow rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;X-Forwarded-Proto&lt;/code&gt; is the canonical way to detect protocol behind a reverse proxy.&lt;/strong&gt; Not just for Apache — Express, Flask, Django, every web framework has equivalent helpers (&lt;code&gt;req.protocol&lt;/code&gt; with &lt;code&gt;trust proxy&lt;/code&gt;, &lt;code&gt;request.is_secure&lt;/code&gt;, &lt;code&gt;request.scheme&lt;/code&gt; with &lt;code&gt;SECURE_PROXY_SSL_HEADER&lt;/code&gt;). If you've ever wondered why your framework "thinks" HTTPS is HTTP, this is usually the answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Soft 404s are not the same as 404s.&lt;/strong&gt; Google treats them differently. If a page is permanently gone, return 410. If a page is broken right now but might come back, return 503. If you're just genuinely unsure, 404. The status code matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test redirect rules on a staging copy first.&lt;/strong&gt; I didn't, because the rule was "obvious." It wasn't. Twenty minutes of staging would have saved me three hours of production hot-fixing. I keep saying I've learned this lesson and I keep proving I haven't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search Console mostly fixes itself if you fix the underlying signals.&lt;/strong&gt; The soft 404 cleanup showed me that GSC reports are largely a function of what your server actually sends. Once the server stops sending bad signals, the reports clear, often without further intervention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;If you're hosting on SiteGround or any similar Nginx-in-front-of-Apache stack and you've ever had a redirect rule that "just doesn't work," &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; is probably what you need.&lt;/p&gt;

&lt;p&gt;If you have an old site with URLs that no longer exist, switching them from soft 404 to 410 Gone is one of the lowest-effort, highest-leverage hygiene moves available — especially if Google's been quietly downranking those URLs for years.&lt;/p&gt;

&lt;p&gt;And if you've ever taken down your own site at 2am, welcome. The club has many members.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The Hidden Complexity of Two-Player Browser Games — A Practical Guide to Keyboard Input</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Thu, 30 Apr 2026 07:35:09 +0000</pubDate>
      <link>https://dev.to/imagebear/the-hidden-complexity-of-two-player-browser-games-a-practical-guide-to-keyboard-input-4gea</link>
      <guid>https://dev.to/imagebear/the-hidden-complexity-of-two-player-browser-games-a-practical-guide-to-keyboard-input-4gea</guid>
      <description>&lt;p&gt;I run &lt;a href="https://2playerfun.com/" rel="noopener noreferrer"&gt;2playerfun.com&lt;/a&gt;, a site dedicated entirely to two-player browser games where both players share one keyboard. It's a niche that turned out to have more technical depth than I expected.&lt;/p&gt;

&lt;p&gt;When I started, I assumed local multiplayer was the easy case. No netcode. No latency. No matchmaking. Just two players, one keyboard, browser-side state. What could go wrong?&lt;/p&gt;

&lt;p&gt;What could go wrong, it turns out, is that two people pressing keys on a single keyboard at the same time is a surprisingly hostile environment.&lt;/p&gt;

&lt;p&gt;This post is about what I've learned. If you're building a local-multiplayer browser game, or you've just always wondered why every two-player web game uses WASD and arrows specifically, read on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keyboard hardware actually matters
&lt;/h2&gt;

&lt;p&gt;The thing nobody tells you when you start: keyboards have a property called &lt;strong&gt;N-key rollover&lt;/strong&gt; (NKRO), and most keyboards don't have very good NKRO.&lt;/p&gt;

&lt;p&gt;In practice this means: a cheap membrane keyboard might only register two or three simultaneous key presses correctly. If Player 1 is holding W and A to move diagonally, and Player 2 presses an arrow key at the same time, the keyboard might just not send the third event. Or worse, it might send a "ghost" event for a key that wasn't actually pressed.&lt;/p&gt;

&lt;p&gt;This is hardware. There's nothing you can do about it from the browser. But there are things you can do that make the problem less common.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why every 2P game uses WASD + Arrows
&lt;/h2&gt;

&lt;p&gt;If you've played any local multiplayer browser game, you've seen this layout:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Player 1&lt;/strong&gt;: WASD (movement) + nearby action keys (Q, E, F, Space)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Player 2&lt;/strong&gt;: Arrow keys (movement) + nearby action keys (Enter, comma, period, M, /)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn't an accident. WASD and arrow keys live on opposite sides of the keyboard, on different rows of the underlying key matrix. On most keyboards, this means they're far less likely to ghost into each other than, say, WASD and TFGH would be.&lt;/p&gt;

&lt;p&gt;You can almost always press WASD + arrows + 2–3 nearby action keys simultaneously without issues, even on a cheap keyboard. That's why the convention exists. It's a hardware-aware layout choice that the genre converged on through trial and error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: if you're inventing your own scheme, don't put both players' keys in the same physical region of the keyboard. Use the WASD/arrow split unless you have a strong reason not to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser event handling — the things that bit me
&lt;/h2&gt;

&lt;p&gt;Once you've got your key layout, you have to actually capture the inputs in the browser. Here's where I learned a few things the hard way.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Use &lt;code&gt;keydown&lt;/code&gt; and &lt;code&gt;keyup&lt;/code&gt;, not &lt;code&gt;keypress&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;keypress&lt;/code&gt; event is deprecated and doesn't fire for non-character keys like arrows or modifiers. Use &lt;code&gt;keydown&lt;/code&gt; for "key went down" and &lt;code&gt;keyup&lt;/code&gt; for "key was released":&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&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;e&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="c1"&gt;// Handle press&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keyup&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;e&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="c1"&gt;// Handle release&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Use &lt;code&gt;event.code&lt;/code&gt;, not &lt;code&gt;event.key&lt;/code&gt; or &lt;code&gt;event.keyCode&lt;/code&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;event.keyCode&lt;/code&gt; is deprecated.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;event.key&lt;/code&gt; returns the &lt;em&gt;character&lt;/em&gt; (&lt;code&gt;"a"&lt;/code&gt;, &lt;code&gt;"A"&lt;/code&gt;, &lt;code&gt;"ArrowLeft"&lt;/code&gt;), which changes with keyboard layout (AZERTY vs QWERTY) and shift state.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;event.code&lt;/code&gt; returns the &lt;em&gt;physical key location&lt;/em&gt; (&lt;code&gt;"KeyA"&lt;/code&gt;, &lt;code&gt;"ArrowLeft"&lt;/code&gt;), which is consistent across layouts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For game input, you almost always want the physical key, not the character. Use &lt;code&gt;event.code&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&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;e&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="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;KeyW&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nx"&gt;player1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;up&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;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ArrowUp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;player2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;up&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;break&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;h3&gt;
  
  
  3. Handle key repeat correctly
&lt;/h3&gt;

&lt;p&gt;When a key is held, the browser fires &lt;code&gt;keydown&lt;/code&gt; repeatedly. For movement keys this is fine — your game loop reads the state every frame. For action keys (jump, shoot), you want to fire the action only on initial press, not on every repeat.&lt;/p&gt;

&lt;p&gt;Two common patterns:&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="c1"&gt;// Pattern A: skip repeats with event.repeat&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&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;e&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repeat&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Space&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;player1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;jump&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Pattern B: track held keys, fire action on transition&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heldKeys&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&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;e&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;heldKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&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;heldKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Space&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;player1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;jump&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keyup&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;e&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;heldKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&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;I prefer Pattern B because it gives you a clean "what's currently held" state for movement, plus reliable transition detection for actions, from the same data structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. &lt;code&gt;preventDefault&lt;/code&gt; aggressively (but not indiscriminately)
&lt;/h3&gt;

&lt;p&gt;Browsers have default behavior for many keys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Space scrolls the page.&lt;/li&gt;
&lt;li&gt;Arrow keys scroll the page.&lt;/li&gt;
&lt;li&gt;Tab moves focus.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/&lt;/code&gt; may open browser search.&lt;/li&gt;
&lt;li&gt;F1–F12 do various OS/browser things.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your game uses any of these for input, call &lt;code&gt;event.preventDefault()&lt;/code&gt; or the browser hijacks the key:&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;gameKeys&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Space&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ArrowUp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ArrowDown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ArrowLeft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ArrowRight&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;KeyW&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;KeyA&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;KeyS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;KeyD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&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;e&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gameKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... handle game input&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whitelist your game keys. Don't blanket-&lt;code&gt;preventDefault&lt;/code&gt; everything — users still need Ctrl+R, Ctrl+Tab, and so on for browser functions. I've seen games that swallow Tab and break focus navigation site-wide. Don't be that game.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Focus matters
&lt;/h3&gt;

&lt;p&gt;Your game needs focus to receive keyboard events. If you're rendering into a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;, the canvas itself doesn't receive keyboard events by default. Two options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Listen on &lt;code&gt;window&lt;/code&gt; — simplest, but globally captures keys.&lt;/li&gt;
&lt;li&gt;Make the canvas focusable with &lt;code&gt;tabindex="0"&lt;/code&gt; and listen on the canvas — better for embeddable games.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a dedicated 2P game page, &lt;code&gt;window&lt;/code&gt; is fine. For games embedded inside a larger page, scope to the canvas.&lt;/p&gt;

&lt;h2&gt;
  
  
  The input architecture I actually use
&lt;/h2&gt;

&lt;p&gt;Here's the pattern I use across 2playerfun games:&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;players&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;up&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;down&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;p2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;up&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;down&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keyMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;KeyW&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;up&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;KeyS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;down&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;KeyA&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;left&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;KeyD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;right&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;Space&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;action&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;

  &lt;span class="na"&gt;ArrowUp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;up&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;ArrowDown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;down&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;ArrowLeft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;left&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;ArrowRight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;right&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;Enter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;action&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&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;e&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;mapping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;keyMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&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;mapping&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;what&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;players&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;what&lt;/span&gt;&lt;span class="p"&gt;]&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;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keyup&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;e&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;mapping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;keyMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&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;mapping&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;what&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;players&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;what&lt;/span&gt;&lt;span class="p"&gt;]&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;span class="c1"&gt;// In game loop:&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dt&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;players&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;up&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="nx"&gt;p1Sprite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;speed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dt&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;players&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;down&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="nx"&gt;p1Sprite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;speed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dt&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;players&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;p1&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;p1Sprite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;speed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dt&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;players&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;p1&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="nx"&gt;p1Sprite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;speed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ... same for p2&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key idea: keep input state in a structured object that the game loop reads, instead of mutating game state directly in event handlers. This decouples input from game logic and makes a few things much easier:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Customizable key bindings&lt;/strong&gt; — just rebuild &lt;code&gt;keyMap&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay systems&lt;/strong&gt; — serialize the players state per frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI opponents&lt;/strong&gt; — let the AI write to the state object the same way the keyboard does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Networked multiplayer later&lt;/strong&gt; — the state object is what you'd send over the wire.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This pattern looks trivial in isolation, but the savings compound over a dozen games.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mobile/touch reality
&lt;/h2&gt;

&lt;p&gt;Honest answer: local multiplayer on mobile is mostly broken.&lt;/p&gt;

&lt;p&gt;In theory you can put two touch zones on screen — one player on the left, one on the right. In practice this works for very simple games (tap-to-jump, swipe-to-move) but breaks down for anything needing simultaneous directional + action input. The device is also held by one person, which adds awkward physical dynamics.&lt;/p&gt;

&lt;p&gt;For 2playerfun, my pragmatic answer is: most games are desktop-only, and mobile users see a polite "this game works best on a desktop with two players sharing a keyboard" message. Trying to force mobile two-player support has been more trouble than it's worth, and the audience that wants couch co-op is mostly on laptops anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I wish I'd known earlier
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Test on a cheap keyboard.&lt;/strong&gt; Your nice mechanical board with full NKRO will tell you nothing about what 90% of your users experience. Buy a $15 USB membrane keyboard and test 2P games on it. You will find ghosting issues you didn't know existed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;event.code&lt;/code&gt; from day one.&lt;/strong&gt; Switching from &lt;code&gt;event.key&lt;/code&gt; or &lt;code&gt;event.keyCode&lt;/code&gt; later means rewriting input across every game. Save yourself the rework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't blanket-preventDefault.&lt;/strong&gt; Be specific about which keys your game uses, and let the rest behave normally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build the input layer once, reuse it.&lt;/strong&gt; The keyMap → state object → game-loop-reads-state pattern works for almost every local 2P game I've built. Once it's working, copy it forward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Local multiplayer browser games feel like a niche from 2008, but the genre is having a quiet renaissance. People want to play games together in person again. Sharing a couch and a laptop is a perfectly valid form of multiplayer, and the browser is an extremely accessible platform for it — no installs, no accounts, no friend codes, no matchmaking.&lt;/p&gt;

&lt;p&gt;If you're building one, I hope some of this saves you a few hours.&lt;/p&gt;

&lt;p&gt;If you're just looking to play one, &lt;a href="https://2playerfun.com/" rel="noopener noreferrer"&gt;2playerfun.com&lt;/a&gt; has a couple hundred of them, all using roughly the input architecture above.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>gamedev</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a Gacha Tower Defense in Cocos Creator: Wave System, Merge Logic, and 28 Enemy Types</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Fri, 24 Apr 2026 10:41:35 +0000</pubDate>
      <link>https://dev.to/imagebear/building-a-gacha-tower-defense-in-cocos-creator-wave-system-merge-logic-and-28-enemy-types-4hl5</link>
      <guid>https://dev.to/imagebear/building-a-gacha-tower-defense-in-cocos-creator-wave-system-merge-logic-and-28-enemy-types-4hl5</guid>
      <description>&lt;p&gt;I wrote recently about migrating from LayaAir to Cocos Creator. This post is the follow-up: what I actually built with Cocos after the migration settled.&lt;/p&gt;

&lt;p&gt;The game is Cosmic Summon, a gacha merge tower defense. Players summon heroes randomly, place them on a grid, and combine duplicates to evolve them through seven rarity tiers. Enemies spawn in 50 waves themed around real constellations. Bosses appear every five waves.&lt;/p&gt;

&lt;p&gt;This post walks through the technical decisions behind three of the core systems: the merge mechanic, the wave progression, and the enemy variety system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Merge Mechanic
&lt;/h2&gt;

&lt;p&gt;The merge mechanic is the heart of the gameplay loop. When a player places two heroes of the same type on adjacent grid cells, they combine into a higher-tier version of that hero.&lt;/p&gt;

&lt;p&gt;Implementing this cleanly in Cocos Creator came down to three decisions.&lt;/p&gt;

&lt;p&gt;First, the grid is a logical structure, not a visual one. Heroes are rendered at pixel-perfect positions but their merge eligibility is determined by grid coordinates stored on the hero component itself:&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="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;ccclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HeroUnit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HeroUnit&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;property&lt;/span&gt;
  &lt;span class="nx"&gt;heroType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&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="nd"&gt;property&lt;/span&gt;
  &lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;property&lt;/span&gt;
  &lt;span class="nx"&gt;gridX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;property&lt;/span&gt;
  &lt;span class="nx"&gt;gridY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="nf"&gt;canMergeWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;other&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HeroUnit&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;heroType&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;other&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;heroType&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;other&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isAdjacent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;other&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;isAdjacent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;other&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HeroUnit&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dx&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;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gridX&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;other&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gridX&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;dy&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;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gridY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;other&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gridY&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;dx&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="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;Second, the merge animation is handled by the Cocos Animation component, not by manually tweening properties. The animation plays on a temporary placeholder node while the old heroes are destroyed and the new hero is instantiated underneath. This lets the visual feel smooth even when the game logic is doing three things at once.&lt;/p&gt;

&lt;p&gt;Third, the merge trigger is evaluated on placement, not continuously. The old approach ran merge checks every frame, which was fine for small boards but slowed down on mobile with 20+ units on screen. Evaluating only on placement cut the CPU overhead substantially.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Wave Progression System
&lt;/h2&gt;

&lt;p&gt;50 waves is enough that designing them manually would be tedious and error-prone. I built a wave configuration system based on JSON data, evaluated at runtime:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;WaveConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;waveNumber&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="nl"&gt;constellation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;spawns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EnemySpawn&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;isBossWave&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;durationSeconds&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;EnemySpawn&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;enemyType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;count&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="nl"&gt;delayMs&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="nl"&gt;spawnPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;single&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cluster&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stream&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;The wave data lives in a JSON file shipped with the game. At runtime, the wave manager reads the config for the current wave, schedules enemy spawns through Cocos's scheduler, and triggers the wave completion event when all enemies are defeated or reach the base.&lt;/p&gt;

&lt;p&gt;This separation of wave configuration from wave logic made iteration vastly faster. When playtesting revealed that wave 23 was too easy, I adjusted the JSON file, not the code.&lt;/p&gt;

&lt;p&gt;One useful pattern: rather than spawning enemies instantly, each spawn is scheduled with a delay. This gives waves a sense of rhythm rather than a wall of enemies arriving simultaneously. For the 5-wave boss cadence, the boss spawn is preceded by a 2-second pause and a visual warning effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Enemy Variety System
&lt;/h2&gt;

&lt;p&gt;28 enemy types sounds like a lot, but the complexity comes from how they interact with each other and with the player's hero composition, not from the types being mechanically unique.&lt;/p&gt;

&lt;p&gt;Each enemy is a composition of behaviors rather than a monolithic class:&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="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;ccclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enemy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Enemy&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;property&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="nx"&gt;behaviors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;property&lt;/span&gt;
  &lt;span class="nx"&gt;hp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;property&lt;/span&gt;
  &lt;span class="nx"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nf"&gt;takeDamage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&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;const&lt;/span&gt; &lt;span class="nx"&gt;reflect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBehavior&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ReflectBehavior&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;reflect&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;reflect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shouldReflect&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;reflect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reflectDamage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&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="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hp&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hp&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onDeath&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;onDeath&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;split&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBehavior&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SplitBehavior&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;split&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;split&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spawnSplitUnits&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;getBehavior&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Component&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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;T&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;behaviors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;T&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behaviors are reusable: StealthBehavior vanishes for N seconds, SplitBehavior spawns smaller enemies on death, ReflectBehavior bounces damage, SummonBehavior spawns minions during lifetime, ShieldBehavior absorbs a fixed amount before taking hit damage.&lt;/p&gt;

&lt;p&gt;A Reflector Splitter enemy is simply an enemy with both ReflectBehavior and SplitBehavior attached. This compositional approach made adding new enemy types trivial, usually just a new config entry referencing existing behaviors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance Considerations
&lt;/h2&gt;

&lt;p&gt;With up to 40 active enemies plus 15 heroes plus projectiles plus UI, the scene can hit several hundred nodes during peak combat. A few patterns that helped.&lt;/p&gt;

&lt;p&gt;Object pooling for projectiles and hit effects. These are spawned frequently and destroyed frequently. Pooling eliminates the allocation cost:&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;pool&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;NodePool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Projectile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&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;projectile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;instantiate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;projectilePrefab&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;projectile&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Avoid per-frame array allocations in update loops. Reusing a single array reference across frames rather than creating new arrays each update saved measurable frame time on lower-end phones.&lt;/p&gt;

&lt;p&gt;Batching sprite draws. Cocos Creator supports auto-batching for sprites in the same atlas. Grouping projectile sprites and enemy sprites into their respective atlases meant the whole scene rendered in a handful of draw calls rather than dozens.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently Next Time
&lt;/h2&gt;

&lt;p&gt;Two things.&lt;/p&gt;

&lt;p&gt;First, I'd design the merge system's edge cases earlier. The current implementation handles the common cases well but had weird behavior when players rapidly placed and removed units in the same frame. Fixing it required refactoring the placement event queue late in development.&lt;/p&gt;

&lt;p&gt;Second, I'd build a dev-mode wave editor from the start. I eventually built one to accelerate balance testing, but the first 30 waves were designed with manual JSON editing, which was slow and error-prone. An in-editor tool would have paid for itself many times over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Playable
&lt;/h2&gt;

&lt;p&gt;Cosmic Summon runs in any modern browser at &lt;a href="https://phyfun.com" rel="noopener noreferrer"&gt;phyfun.com&lt;/a&gt; with no download or account required. The iOS version is currently in App Store review.&lt;/p&gt;

&lt;p&gt;If you're building anything in Cocos Creator and want to see a working example of these patterns, the game is a decent reference for gacha systems, merge mechanics, and wave-based progression in a single project.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was written with AI assistance.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>cocoscreator</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>From LayaAir to Cocos Creator: A Solo Dev's Engine Migration After iOS Builds Kept Crashing</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Mon, 20 Apr 2026 02:13:31 +0000</pubDate>
      <link>https://dev.to/imagebear/from-layaair-to-cocos-creator-a-solo-devs-engine-migration-after-ios-builds-kept-crashing-5bcn</link>
      <guid>https://dev.to/imagebear/from-layaair-to-cocos-creator-a-solo-devs-engine-migration-after-ios-builds-kept-crashing-5bcn</guid>
      <description>&lt;h2&gt;
  
  
  The debug build that wouldn't stop crashing
&lt;/h2&gt;

&lt;p&gt;It was 2 AM. I had just spent five days trying to fix the same bug.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://phyfun.com/game/cosmic-summon-tower-defense-27195" rel="noopener noreferrer"&gt;My game&lt;/a&gt; ran perfectly in the browser — 60 FPS, smooth animations, clean input handling. Then I'd run the iOS debug build and watch it crash within 30 seconds of launch. Sometimes on scene load. Sometimes mid-gameplay. Sometimes on a simple button tap.&lt;/p&gt;

&lt;p&gt;No consistent stack trace. No clear reproduction path. Just crashes.&lt;/p&gt;

&lt;p&gt;That week, I decided to migrate the entire project from &lt;strong&gt;LayaAir&lt;/strong&gt; to &lt;strong&gt;&lt;a href="https://www.cocos.com/en/creator" rel="noopener noreferrer"&gt;Cocos Creator&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This post is about why I made that call, what the migration actually looked like as a solo developer, and what I'd tell anyone considering either engine today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I chose LayaAir in the first place
&lt;/h2&gt;

&lt;p&gt;When I started building &lt;a href="https://phyfun.com" rel="noopener noreferrer"&gt;browser-first games&lt;/a&gt;, LayaAir was an obvious pick:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Strong WebGL performance&lt;/strong&gt; — genuinely fast in the browser, often beating Cocos and Phaser in my side-by-side tests&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript-first&lt;/strong&gt; — clean workflow, great autocomplete, sane codebase&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Good for 2D casual games&lt;/strong&gt; — which is exactly what I build&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Active in the Chinese dev community&lt;/strong&gt; — a lot of reference material if you can read it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For about 8 months, this was the right choice. My physics games and puzzle games shipped to the browser, loaded fast, and ran well on desktop and mobile browsers alike.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where LayaAir shines: Web builds
&lt;/h2&gt;

&lt;p&gt;Let me be fair to LayaAir. For &lt;strong&gt;web-only targets&lt;/strong&gt;, it's still a solid engine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Small runtime size&lt;/li&gt;
&lt;li&gt;Fast startup&lt;/li&gt;
&lt;li&gt;Good rendering performance on WebGL&lt;/li&gt;
&lt;li&gt;Reasonable TypeScript DX&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I were building a game that would only ever run in a browser, I'd still consider it.&lt;/p&gt;

&lt;p&gt;The problems started when I tried to go native.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it broke: iOS native builds
&lt;/h2&gt;

&lt;p&gt;My goal was to ship the same game to iOS. LayaAir advertises native export to iOS via its build pipeline — in theory, you get an Xcode project from your web codebase.&lt;/p&gt;

&lt;p&gt;In practice, the iOS output was unstable in ways I couldn't engineer around:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Debug builds crashed unpredictably.&lt;/strong&gt; Same code that ran flawlessly in the browser would segfault on device, often with stack traces pointing into the engine's native layer, not my code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory-related crashes&lt;/strong&gt; during scene transitions, even in a simple project with minimal assets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inconsistent behavior between simulator and real device&lt;/strong&gt; — a bug that reproduced on iPhone 14 might not show on the simulator, and vice versa.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Very limited English-language debugging resources.&lt;/strong&gt; Most issue discussions were in Chinese, and even there, many reported bugs had no resolution.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I spent five full days trying to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Strip the project down to a minimal reproduction&lt;/li&gt;
&lt;li&gt;Experiment with different build configurations&lt;/li&gt;
&lt;li&gt;Try every combination of engine version and Xcode version I could find&lt;/li&gt;
&lt;li&gt;Post on forums and wait for responses that never came&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The issue wasn't in my code. It was in the engine's native bridge. And as a solo developer, I don't have the budget — in time or money — to debug someone else's native runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  The switch decision
&lt;/h2&gt;

&lt;p&gt;Here's the framing that helped me decide:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"How many more days am I willing to spend on a problem I can't see the bottom of?"&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the engine's iOS layer was buggy enough that a minimal project crashed, what would happen when I added monetization SDKs, more assets, more scenes?&lt;/p&gt;

&lt;p&gt;I gave myself a budget: two more days. If I didn't have a stable build by then, I'd switch engines.&lt;/p&gt;

&lt;p&gt;I didn't make it to day two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating to Cocos Creator
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.cocos.com/en/creator" rel="noopener noreferrer"&gt;Cocos Creator&lt;/a&gt; was the natural alternative:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Also TypeScript-based&lt;/li&gt;
&lt;li&gt;Similar scene/node/component architecture to LayaAir&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;61% of top 100 WeChat mini games ship on Cocos&lt;/strong&gt; — which tells me the native export pipeline is battle-tested&lt;/li&gt;
&lt;li&gt;MIT-licensed and actively maintained&lt;/li&gt;
&lt;li&gt;Broader international documentation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The migration took about &lt;strong&gt;10 days&lt;/strong&gt; of focused work for a medium-sized project (a merge-gacha tower defense with around 40 scenes).&lt;/p&gt;

&lt;p&gt;Here's what actually transferred well and what didn't:&lt;/p&gt;

&lt;h3&gt;
  
  
  What was easy
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Game logic and data structures&lt;/strong&gt; — pure TypeScript business logic ported with minimal changes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scene structure concepts&lt;/strong&gt; — both engines use a tree of nodes with components attached. The mental model is nearly identical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asset pipeline&lt;/strong&gt; — sprites, atlases, and audio imported cleanly&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What needed rewriting
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scene transitions and lifecycle hooks&lt;/strong&gt; — API names and timing differ enough that I rewrote all the transition code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input handling&lt;/strong&gt; — the event system is different; I consolidated my input layer into a single adapter module&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI layout&lt;/strong&gt; — LayaAir and Cocos have different layout primitives. I redid most of my UI by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Animation system&lt;/strong&gt; — different timeline formats; I reimplemented the important ones&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What surprised me
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cocos's editor is heavier but more stable.&lt;/strong&gt; LayaAir IDE is lighter, but I hit editor crashes regularly. Cocos Creator's editor has held up through multi-hour sessions without issue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The iOS export just worked.&lt;/strong&gt; The first time I tried to build for iOS with Cocos Creator, it compiled, ran on device, and didn't crash. After five days of LayaAir debugging, this felt absurd.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web performance is still good.&lt;/strong&gt; I was worried I'd lose the web performance edge. In practice, for my 2D casual games, the difference is imperceptible.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Results after the switch
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cosmic Summon&lt;/strong&gt; (my gacha merge tower defense) now runs on both &lt;a href="https://phyfun.com/game/cosmic-summon-tower-defense-27195" rel="noopener noreferrer"&gt;browser&lt;/a&gt; and &lt;a href="https://apps.apple.com/app/id6760743174" rel="noopener noreferrer"&gt;iOS&lt;/a&gt; from the same Cocos Creator project&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Juicy Ricochet&lt;/strong&gt; shipped to the App Store as an all-Cocos build&lt;/li&gt;
&lt;li&gt;Zero engine-layer crashes since the migration&lt;/li&gt;
&lt;li&gt;My development velocity actually increased once I was past the migration hump — the editor stability alone was worth it&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell someone in the same situation
&lt;/h2&gt;

&lt;p&gt;A few things I learned the hard way:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Set a debugging budget before you start.&lt;/strong&gt;&lt;br&gt;
"I'll fix this bug eventually" is how you lose two weeks. Decide in advance how many days the problem is worth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Engine problems in the native layer are usually not solvable as a user.&lt;/strong&gt;&lt;br&gt;
If the crash comes from inside the engine's C++ or Objective-C code and isn't fixed upstream, you're not going to fix it. Move on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Web performance isn't the only metric.&lt;/strong&gt;&lt;br&gt;
I chose LayaAir partly because its benchmarks were great. But "faster in a controlled web benchmark" didn't matter when the iOS build didn't work at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Pick the engine that ships the platforms you need — not the one that ships the benchmark you like.&lt;/strong&gt;&lt;br&gt;
Cocos Creator wasn't the "fastest" engine on paper for my use case. But it shipped everywhere I needed to ship, and that turned out to be the only metric that mattered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Don't mistake language comfort for engine fit.&lt;/strong&gt;&lt;br&gt;
Both engines use TypeScript. I liked LayaAir's API aesthetically. But aesthetic preference shouldn't outrank "my game crashes on the target platform."&lt;/p&gt;

&lt;h2&gt;
  
  
  Is LayaAir bad? No.
&lt;/h2&gt;

&lt;p&gt;I want to be clear: LayaAir isn't a bad engine. For &lt;strong&gt;web-only&lt;/strong&gt; projects it's still a reasonable choice, and I know developers shipping successful games on it.&lt;/p&gt;

&lt;p&gt;My specific experience was with the &lt;strong&gt;iOS native export&lt;/strong&gt;, which didn't work reliably for my project. Your mileage may vary, especially if you're targeting different platforms or a newer engine version.&lt;/p&gt;

&lt;p&gt;But if you're a solo developer, and stability across platforms matters more to you than peak web benchmark performance — &lt;strong&gt;Cocos Creator is, in 2026, the safer bet&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Currently building:&lt;/strong&gt; Cosmic Summon, a gacha merge tower defense live on &lt;a href="https://phyfun.com/game/cosmic-summon-tower-defense-27195" rel="noopener noreferrer"&gt;browser&lt;/a&gt; and &lt;a href="https://apps.apple.com/app/id6760743174" rel="noopener noreferrer"&gt;iOS&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More of my games:&lt;/strong&gt; &lt;a href="https://phyfun.com" rel="noopener noreferrer"&gt;phyfun.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What engine are you using for your indie game? Have you migrated between engines before? I'd love to hear how it went — drop a comment.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>typescript</category>
      <category>indiedev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I Used Keyword Research to Build a Niche Naming Site</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Thu, 16 Apr 2026 08:33:53 +0000</pubDate>
      <link>https://dev.to/imagebear/how-i-used-keyword-research-to-build-a-niche-naming-site-5ag5</link>
      <guid>https://dev.to/imagebear/how-i-used-keyword-research-to-build-a-niche-naming-site-5ag5</guid>
      <description>&lt;p&gt;I'm an indie developer based in China. Most of my projects are browser games — physics puzzles, sorting games, two-player titles. But one of the most interesting things I've built has nothing to do with games at all.&lt;/p&gt;

&lt;p&gt;It's a dog names site.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://idognames.com" rel="noopener noreferrer"&gt;iDogNames.com&lt;/a&gt; now has over 11,000 dog names searchable by breed, origin, coat color, and meaning. It gets consistent organic traffic. And it started entirely from keyword research, not from any personal passion for dog names.&lt;/p&gt;

&lt;p&gt;Here's how the thinking went.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with Competitive Niches
&lt;/h2&gt;

&lt;p&gt;When I was looking for new content site opportunities, I kept running into the same wall: anything with obvious commercial value was already dominated by large publishers with enormous content budgets. "Best dog food", "puppy training tips", "dog breeds" — these are all real search categories, but ranking for them as a solo developer with no content team is not realistic.&lt;/p&gt;

&lt;p&gt;What keyword research actually reveals, if you look carefully enough, is not just where the traffic is — it's where the traffic is relative to the competition. High traffic with low competition is the obvious target. But that combination is rare.&lt;/p&gt;

&lt;p&gt;The more interesting question is: where is there consistent, specific demand that large publishers haven't bothered to serve well?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Long Tail of Naming Queries
&lt;/h2&gt;

&lt;p&gt;Dog name queries turned out to be a surprisingly good answer to that question.&lt;/p&gt;

&lt;p&gt;The obvious terms — "dog names", "puppy names", "cute dog names" — are competitive. Large pet content sites rank for these and they're not worth targeting directly.&lt;/p&gt;

&lt;p&gt;But dog name queries have an unusually long tail. People don't just search for "dog names". They search for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Romanian dog names"&lt;/li&gt;
&lt;li&gt;"dog names that mean shadow"&lt;/li&gt;
&lt;li&gt;"names for black mouth cur dogs"&lt;/li&gt;
&lt;li&gt;"Amish dog names"&lt;/li&gt;
&lt;li&gt;"dog names inspired by Broadway musicals"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these is a small query. But there are hundreds of them. And most of them are served poorly — generic name lists with no real depth, or large sites that mention the topic once in a listicle and move on.&lt;/p&gt;

&lt;p&gt;The pattern I was looking for: specific intent, real search volume, weak existing results. Dog name queries fit this pattern well across hundreds of variations.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Weak Existing Results" Actually Means
&lt;/h2&gt;

&lt;p&gt;This is worth being specific about, because "low competition" means different things depending on what you're building.&lt;/p&gt;

&lt;p&gt;For a content site targeting informational queries, weak existing results means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The top results are generic pages that don't specifically address the query&lt;/li&gt;
&lt;li&gt;Large domain authority sites are ranking but with thin, unspecific content&lt;/li&gt;
&lt;li&gt;No dedicated resource exists for this specific combination of topic and modifier&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Romanian dog names" is a good example. When I looked at this query, the top results were generic dog name lists that happened to mention Romanian names in passing, or articles about Romanian dog breeds with a name section appended. There was no page built specifically around Romanian dog names with real depth — cultural context, name meanings, male and female options.&lt;/p&gt;

&lt;p&gt;That's the gap. Not "nobody is ranking" — there are always results — but "nobody has built the right page for this specific query."&lt;/p&gt;

&lt;h2&gt;
  
  
  Building for the Long Tail at Scale
&lt;/h2&gt;

&lt;p&gt;The insight that made iDogNames work as a project rather than a single article is that the long tail of naming queries is consistent in structure.&lt;/p&gt;

&lt;p&gt;Almost every naming query follows a pattern: [modifier] + dog names. The modifier can be a breed, an origin country, a color, a meaning, a theme, a holiday, a cultural reference. If you can build a system that generates good pages for each modifier, you can cover the long tail systematically rather than one article at a time.&lt;/p&gt;

&lt;p&gt;This changes the economics of the project. Instead of writing 500 individual articles, you build a database and a page template that serves queries well across all the variations. The content work shifts from writing to data curation — which, as a developer, is a much more tractable problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Traffic Data Actually Shows
&lt;/h2&gt;

&lt;p&gt;The queries that perform best on iDogNames are consistently the specific ones, not the broad ones.&lt;/p&gt;

&lt;p&gt;Pages targeting breed-specific queries ("Black Mouth Cur dog names", "Presa Canario dog names") perform well because the intent is very specific — someone just got this breed of dog and wants names that fit. Generic name lists don't serve them as well as a dedicated page.&lt;/p&gt;

&lt;p&gt;Cultural and origin queries ("Amish dog names", "Serbian dog names", "Swahili dog names") perform well for the same reason — the searcher has a specific context in mind and wants names that fit that context.&lt;/p&gt;

&lt;p&gt;Meaning-based queries ("dog names that mean shadow", "dog names that mean light") perform well because the searcher is approaching the problem from a values or aesthetics perspective and existing results rarely address this directly.&lt;/p&gt;

&lt;p&gt;The broad queries — "dog names", "cute dog names" — drive less of the traffic than you might expect. The long tail collectively outperforms the head terms, which is exactly what keyword research predicted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Transferable Lesson
&lt;/h2&gt;

&lt;p&gt;The specific niche doesn't matter much. What matters is the pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find a category with consistent, specific demand across many query variations&lt;/li&gt;
&lt;li&gt;Look for gaps where specific intent is being served by generic content&lt;/li&gt;
&lt;li&gt;Build a system that covers the long tail at scale rather than targeting individual terms&lt;/li&gt;
&lt;li&gt;Let the data tell you which variations are worth prioritizing&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I've applied the same thinking to &lt;a href="https://icatnames.com" rel="noopener noreferrer"&gt;iCatNames.com&lt;/a&gt; and &lt;a href="https://9babynames.com" rel="noopener noreferrer"&gt;9BabyNames.com&lt;/a&gt; since building iDogNames. The pattern holds — naming queries in general have a long tail that rewards systematic coverage over individual article targeting.&lt;/p&gt;

&lt;p&gt;For solo developers looking for content site opportunities, naming niches are worth considering specifically because the query structure is so consistent. The work is in the data curation and the page quality, not in finding angles — the search data tells you exactly what people are looking for.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build browser games and niche content sites. The games are at &lt;a href="https://phyfun.com" rel="noopener noreferrer"&gt;PhyFun.com&lt;/a&gt;. If you have questions about the keyword research process or the technical side of building iDogNames, happy to discuss in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>indiehacker</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why the Best Educational Games Teach Better Than Textbooks</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Sun, 12 Apr 2026 12:19:27 +0000</pubDate>
      <link>https://dev.to/imagebear/why-the-best-educational-games-teach-better-than-textbooks-282n</link>
      <guid>https://dev.to/imagebear/why-the-best-educational-games-teach-better-than-textbooks-282n</guid>
      <description>&lt;p&gt;I've been building browser games for a while. Physics puzzlers, &lt;br&gt;
sorting games, two-player games — the usual indie developer &lt;br&gt;
portfolio.&lt;/p&gt;

&lt;p&gt;But when I built &lt;a href="https://lumigamelab.com" rel="noopener noreferrer"&gt;LumiGameLab&lt;/a&gt;, I had &lt;br&gt;
to think seriously about a question I'd never considered before: &lt;br&gt;
what actually makes a game educational?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft9osg9kfeubhta21f1aw.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%2Ft9osg9kfeubhta21f1aw.png" alt=" " width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Wrong Answer
&lt;/h2&gt;

&lt;p&gt;The wrong answer is: a game is educational if it covers an &lt;br&gt;
academic subject.&lt;/p&gt;

&lt;p&gt;This definition fails immediately when you play the games. A &lt;br&gt;
game can involve math and teach nothing. A game can look like &lt;br&gt;
pure entertainment and quietly build genuine skills. Subject &lt;br&gt;
matter and educational value are not the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Right Answer
&lt;/h2&gt;

&lt;p&gt;The right answer, I think, is this: a game is educational when &lt;br&gt;
the learning is embedded in the mechanic — not bolted on top of &lt;br&gt;
it as a reward for playing.&lt;/p&gt;

&lt;p&gt;In a bad educational game, learning is the tax you pay to access &lt;br&gt;
the fun part. Answer this question correctly and you get to play &lt;br&gt;
the game. The educational content and the gameplay are separate &lt;br&gt;
layers that don't reinforce each other.&lt;/p&gt;

&lt;p&gt;In a good educational game, learning is the gameplay. You solve &lt;br&gt;
equations because the game requires it. You learn country &lt;br&gt;
locations by placing them on a map under time pressure. You &lt;br&gt;
develop intuitions about gravity and momentum because those &lt;br&gt;
principles determine whether you win or lose.&lt;/p&gt;

&lt;p&gt;The difference between these two approaches is the difference &lt;br&gt;
between a game that feels like homework and a game you'd play &lt;br&gt;
even if it weren't educational.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters for Game Developers
&lt;/h2&gt;

&lt;p&gt;If you're building educational games, this distinction has real &lt;br&gt;
design implications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design the mechanic around the learning objective, not the &lt;br&gt;
other way around.&lt;/strong&gt; If you're building a math game, the math &lt;br&gt;
should be the core loop — not a mini-game attached to an &lt;br&gt;
unrelated action game. Every design decision should ask: does &lt;br&gt;
this reinforce what I want players to learn?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Difficulty and curriculum should scale together.&lt;/strong&gt; The best &lt;br&gt;
educational games are hard in the right way. Early levels build &lt;br&gt;
understanding. Later levels test mastery. The progression should &lt;br&gt;
mirror how people actually learn, not just how games usually &lt;br&gt;
ramp up challenge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Feedback should be informative, not just corrective.&lt;/strong&gt; Telling &lt;br&gt;
a player they got something wrong isn't enough. The feedback loop &lt;br&gt;
should help them understand why — which is harder to design than &lt;br&gt;
it sounds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Engagement is a feature, not a compromise.&lt;/strong&gt; Some people treat &lt;br&gt;
engagement and educational rigor as opposing forces — like making &lt;br&gt;
a game fun necessarily dilutes its educational value. I think &lt;br&gt;
this is backwards. Engagement is the precondition for learning. &lt;br&gt;
A game that bores players teaches nothing, regardless of how &lt;br&gt;
educationally rigorous its content is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Found Building LumiGameLab
&lt;/h2&gt;

&lt;p&gt;When I started curating games for LumiGameLab, I played a lot &lt;br&gt;
of games that claimed to be educational. Most of them failed the &lt;br&gt;
basic test: you could skip the educational element and still play &lt;br&gt;
the game. The learning was optional.&lt;/p&gt;

&lt;p&gt;The games that passed were the ones where the learning was &lt;br&gt;
unavoidable — where you couldn't progress without actually &lt;br&gt;
understanding the concept the game was built around.&lt;/p&gt;

&lt;p&gt;Those games also tended to be the most fun. Not despite being &lt;br&gt;
educational, but because of it. When the challenge is genuine and &lt;br&gt;
the feedback is immediate, the satisfaction of getting it right &lt;br&gt;
is real.&lt;/p&gt;

&lt;p&gt;That's what I try to capture with LumiGameLab. Not games that &lt;br&gt;
are educational in spite of being games, but games that are &lt;br&gt;
better games because of their educational design.&lt;/p&gt;

&lt;p&gt;If you're building in this space, I'd love to hear what design &lt;br&gt;
patterns you've found that make educational mechanics work. Drop &lt;br&gt;
a comment below.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;LumiGameLab is a free educational browser game platform. No &lt;br&gt;
account, no download, no paywalls. Find it at &lt;br&gt;
&lt;a href="https://lumigamelab.com" rel="noopener noreferrer"&gt;lumigamelab.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>gamedev</category>
      <category>learning</category>
    </item>
    <item>
      <title>How I Built a Free Educational Games Website from Scratch</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Fri, 10 Apr 2026 08:46:25 +0000</pubDate>
      <link>https://dev.to/imagebear/how-i-built-a-free-educational-games-website-from-scratch-17hc</link>
      <guid>https://dev.to/imagebear/how-i-built-a-free-educational-games-website-from-scratch-17hc</guid>
      <description>&lt;p&gt;I've been building free browser game sites for a while now. Physics games, sorting games, two-player games — each one started with a specific idea and a gap I noticed in the market.&lt;/p&gt;

&lt;p&gt;LumiGameLab was different. This one started with a question I kept coming back to: why is it so hard to find a good free educational games site?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem I Kept Running Into
&lt;/h2&gt;

&lt;p&gt;There are thousands of free browser game sites on the internet. Most of them follow the same formula — action games, casual games, multiplayer games. Fun, addictive, designed to keep players coming back.&lt;/p&gt;

&lt;p&gt;But educational games? The ones that actually teach something — physics simulations, math challenges, logic puzzles, geography games — were scattered everywhere. Buried under ads. Hard to trust. No single destination where a parent, a teacher, or a curious kid could go and say: everything here is worth playing, and everything here will teach you something.&lt;/p&gt;

&lt;p&gt;That gap is what LumiGameLab is trying to fill.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnygyefrcexth6y4lxkpb.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%2Fnygyefrcexth6y4lxkpb.png" alt=" " width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Educational Games Are Underserved
&lt;/h2&gt;

&lt;p&gt;I think the reason educational game sites are rare comes down to a few things.&lt;/p&gt;

&lt;p&gt;First, educational games are harder to curate. You can't just collect any fun game — you need games with genuine learning value. That's a higher bar, and most aggregator sites don't want to deal with it.&lt;/p&gt;

&lt;p&gt;Second, the audience is different. Parents and teachers have much higher standards for what they'll recommend to kids than casual gamers have for what they'll play themselves. A site full of ads and questionable content is a non-starter. Trust matters.&lt;/p&gt;

&lt;p&gt;Third, the best educational games don't always look educational. A physics puzzle game doesn't announce itself as a lesson in Newton's laws. A geography challenge doesn't feel like homework. The educational value is embedded in the gameplay — and curating for that requires actually playing the games, not just listing them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://lumigamelab.com" rel="noopener noreferrer"&gt;LumiGameLab&lt;/a&gt; is a free educational game platform covering ten subject areas: Math, Puzzle and Logic, Language Arts, Science and Space, Geography, Physics, Memory, Trivia and Quiz, Art and Creativity, and Simulation.&lt;/p&gt;

&lt;p&gt;Math is the largest category with 28 games — not drill exercises, but games where math is the actual mechanic. You solve equations to survive, use multiplication to advance, and apply logic to win.&lt;/p&gt;

&lt;p&gt;The physics games are some of my personal favorites. There's something uniquely satisfying about a game that lets you experiment with real physical principles — momentum, balance, gravity — and see the consequences in real time. It's the kind of intuitive understanding that sticks.&lt;/p&gt;

&lt;p&gt;Geography games bring the world to life in ways static maps never could. Flag quizzes, country placement challenges, capital city games — they turn geography from memorization into exploration.&lt;/p&gt;

&lt;p&gt;The puzzle and logic section is for anyone who wants to feel genuinely challenged. These are the games you think about when you're not playing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical Decisions
&lt;/h2&gt;

&lt;p&gt;The site is built with PHP, which I've used across most of my web projects. Nothing exotic — clean URLs, fast page loads, mobile-friendly layout, minimal JavaScript.&lt;/p&gt;

&lt;p&gt;The main technical challenge was the curation and tagging system. Every game on LumiGameLab is tagged by subject category, topic, age range, and learning focus. Building a flexible tagging system that could handle multiple overlapping categories without becoming a mess took some iteration.&lt;/p&gt;

&lt;p&gt;Search and filtering were also important. A parent looking for math games for a 7-year-old has very different needs from a teacher looking for geography games for a high school class. The filtering system needed to handle that range without becoming complicated to use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Design Philosophy
&lt;/h2&gt;

&lt;p&gt;Two principles guided every decision:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything is free.&lt;/strong&gt; No subscriptions, no paywalls, no premium tiers. Educational resources should be accessible. Not every family can afford an app subscription. Not every school has a licensed software budget. Free isn't a business model compromise — it's the whole point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quality over quantity.&lt;/strong&gt; I'd rather have 100 games that are genuinely good than 1,000 games that happen to mention a subject somewhere in the description. Every game on the site has been reviewed for actual educational value, not just tagged and listed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I've Learned So Far
&lt;/h2&gt;

&lt;p&gt;Building a niche site with a clear educational focus attracts a different kind of visitor than a general game aggregator. The bounce rate is lower. Session time is longer. People are coming with a purpose — a parent looking for something specific for their child, a teacher hunting for a classroom resource, a student who actually wants to learn something.&lt;/p&gt;

&lt;p&gt;That's a more valuable audience to build for, even if it's a smaller one.&lt;/p&gt;

&lt;p&gt;The other thing I've learned: trust takes time to build, but it compounds. A site that consistently delivers quality recommendations builds a reputation that general aggregators can't easily replicate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;LumiGameLab is live now at &lt;a href="https://lumigamelab.com" rel="noopener noreferrer"&gt;lumigamelab.com&lt;/a&gt;. The current version has games across ten subjects, with new games added regularly.&lt;/p&gt;

&lt;p&gt;Upcoming work includes better age-range filtering, teacher resource guides alongside game listings, and expanding the Science and Space category which currently has the fewest games.&lt;/p&gt;

&lt;p&gt;If you're building something in the educational space — games, tools, resources — I'd love to hear what you're working on. Drop a comment below.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build and maintain a small network of free browser-based sites. If you're interested in niche site building, indie game development, or browser-based gaming, follow along.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>gamedev</category>
      <category>learning</category>
      <category>indie</category>
    </item>
    <item>
      <title>Why I Built a Separate Website Just for Sorting Games</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Tue, 31 Mar 2026 13:39:40 +0000</pubDate>
      <link>https://dev.to/imagebear/why-i-built-a-separate-website-just-for-sorting-games-4kja</link>
      <guid>https://dev.to/imagebear/why-i-built-a-separate-website-just-for-sorting-games-4kja</guid>
      <description>&lt;p&gt;When I added the first water color sorting game to my main game portal, I did what most developers do: I tagged it, categorized it, and moved on.&lt;/p&gt;

&lt;p&gt;Six months later I had a dozen sorting games on the site, a noticeable chunk of traffic coming specifically from sorting-related search queries, and a growing suspicion that I was underserving that audience by burying their content inside a general portal.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9faa9deuz4pnkgg0sat7.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%2F9faa9deuz4pnkgg0sat7.png" alt=" " width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That suspicion became SortFun.&lt;/p&gt;

&lt;p&gt;The SEO case for topical authority&lt;/p&gt;

&lt;p&gt;Google's approach to ranking content has shifted significantly toward what SEOs call topical authority — the idea that a site which covers a specific subject comprehensively will outrank a site that covers the same subject shallowly, even if the larger site has more overall domain authority.&lt;/p&gt;

&lt;p&gt;For a general game portal, this creates a structural problem. A site that covers action games, puzzle games, racing games, sports games, and sorting games is authoritative about none of them. It's a generalist competing against specialists for every query it targets.&lt;br&gt;
The math on this became clear when I looked at my search console data. My sorting game pages were getting impressions for queries like "water color sort game" and "ball sort puzzle online," but the click-through rates were lower than I expected. The pages were showing up, but something was making users choose a competitor's result instead.&lt;br&gt;
Part of that was positioning. A page on a general portal that happens to have sorting games is a less convincing answer to "I want to play sorting games" than a site called SortFun where every single page is a sorting game.&lt;/p&gt;

&lt;p&gt;Taxonomy as product decision&lt;/p&gt;

&lt;p&gt;The more interesting engineering decision was how to categorize the games on the new site.&lt;/p&gt;

&lt;p&gt;Traditional game portals organize by genre: puzzle, action, strategy, casual. These categories map onto how games are made, not how players think about what they want to play. A player who wants to sort colored balls into tubes doesn't think "I want a puzzle game." They think "I want that satisfying sorting thing."&lt;/p&gt;

&lt;p&gt;SortFun organizes by mechanic instead of genre. Water sorting. Ball sorting. Tile sliding. Color matching with a sorting constraint. Each category describes what you actually do, not what shelf it belongs on in a game store.&lt;/p&gt;

&lt;p&gt;This distinction matters for SEO because search queries reflect player intent, and player intent is usually mechanic-based. "Water sort puzzle" gets searched. "Casual puzzle game" gets searched less specifically. Building the taxonomy around mechanics means the category pages naturally align with how players actually search.&lt;br&gt;
The single codebase across multiple sites problem&lt;br&gt;
Running multiple specialized sites from a solo developer position creates a maintenance question that's worth thinking through before you commit to the architecture.&lt;/p&gt;

&lt;p&gt;My games are built in Cocos Creator. The web builds are static file bundles — HTML, JavaScript, assets — that I deploy to subdirectories on each site. There's no shared runtime dependency between sites, which means a change to the SortFun deployment doesn't affect PhyFun or 2 Player Fun.&lt;/p&gt;

&lt;p&gt;The tradeoff is that site-level infrastructure changes — navigation updates, footer changes, sitemap regeneration — have to be applied to each site independently. For two or three sites this is manageable. At ten sites it would become a maintenance burden that outweighs the SEO benefits of separation.&lt;/p&gt;

&lt;p&gt;My current threshold: a separate site is justified when the target keyword cluster is large enough to support 50+ unique pages, the mechanic focus is distinct enough that a general portal visitor would feel lost, and the domain name can match the primary keyword naturally. SortFun clears all three. A hypothetical site just for brick-breaker games probably doesn't.&lt;/p&gt;

&lt;p&gt;What the data showed after launch&lt;/p&gt;

&lt;p&gt;The topical authority hypothesis held up. Within a few months of launching SortFun as a standalone domain, the sorting game pages that had previously been buried on a general portal were ranking higher for their target queries than the equivalent pages on the main site, despite the main site having significantly more overall backlinks.&lt;br&gt;
The click-through rate improved as well. A result from SortFun.com for a sorting game query is immediately legible — the domain name tells you exactly what you're going to get. A result from a general portal requires the user to evaluate whether that portal's version of the game is worth clicking over the other results. Specificity removes that friction.&lt;/p&gt;

&lt;p&gt;The tradeoff you're actually making&lt;/p&gt;

&lt;p&gt;Splitting content across multiple specialized sites is a bet that topical authority compounds faster than domain authority. For broad, competitive queries, that bet is probably wrong — you want all your link equity concentrated in one domain. For narrow, underserved query clusters, it can be right.&lt;/p&gt;

&lt;p&gt;Sorting games is a narrow, underserved cluster. The queries are specific, the competition is thinner than in broader puzzle game categories, and players who want sorting games want a lot of sorting games — they're not one-and-done visitors.&lt;/p&gt;

&lt;p&gt;The site is at &lt;a href="https://sortfun.com" rel="noopener noreferrer"&gt;sortfun.com&lt;/a&gt; if you want to see how the taxonomy ended up in practice.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How Keyword Research Led Me to Build a Niche Game Portal</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Tue, 31 Mar 2026 13:32:49 +0000</pubDate>
      <link>https://dev.to/imagebear/how-keyword-research-led-me-to-build-a-niche-game-portal-eij</link>
      <guid>https://dev.to/imagebear/how-keyword-research-led-me-to-build-a-niche-game-portal-eij</guid>
      <description>&lt;p&gt;Most people treat keyword research as an SEO task. I've come to think of it as a business model discovery tool.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffx5n5m51g9oy7ps88f8o.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%2Ffx5n5m51g9oy7ps88f8o.png" alt=" " width="800" height="403"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The site that convinced me of this was 2playerfun.com. The keyword came first. The business followed.&lt;/p&gt;

&lt;p&gt;What I was actually looking for&lt;/p&gt;

&lt;p&gt;Ten years ago I was thinking about browser game portals as a category. The major players — Miniclip, Addicting Games, Newgrounds — were all pursuing the same strategy: maximum breadth, every genre, something for everyone. Their homepages were catalogues. Their category pages were catalogues of catalogues.&lt;/p&gt;

&lt;p&gt;The SEO implication of that strategy is that you compete for everything and dominate nothing. A site that covers every genre will always rank below the threshold for any specific genre query, because specificity is exactly what it's trading away in exchange for breadth.&lt;/p&gt;

&lt;p&gt;I started pulling keyword data for game-related search queries, looking for volume that wasn't being captured by a dedicated destination.&lt;/p&gt;

&lt;p&gt;The keyword that stood out&lt;/p&gt;

&lt;p&gt;"Two player games" had consistent monthly search volume. More importantly, when I looked at what was ranking for it, the results were generic portal pages — category tabs on large sites, not dedicated destinations built around that specific query.&lt;/p&gt;

&lt;p&gt;The keyword had clear intent behind it: someone sitting with another person, looking for something to play together. That's a specific situation with a specific need. A site built entirely around that need would be more useful to that searcher than page 12 of a general portal's two-player category.&lt;/p&gt;

&lt;p&gt;I looked at adjacent keywords to understand the full shape of the opportunity:&lt;br&gt;
"games for two players" — similar volume, same intent&lt;br&gt;
"2 player games unblocked" — significant volume, school audience&lt;br&gt;
"two player games on one keyboard" — lower volume, high specificity&lt;br&gt;
"games to play with friends on computer" — broader intent, overlapping audience&lt;/p&gt;

&lt;p&gt;The cluster confirmed what the primary keyword suggested. There was a real, recurring search demand for exactly this kind of content, and no site was serving it as a primary focus.&lt;/p&gt;

&lt;p&gt;What made the niche viable beyond search volume&lt;/p&gt;

&lt;p&gt;Search volume tells you that demand exists. It doesn't tell you whether the demand is sustainable or whether you can actually serve it well.&lt;/p&gt;

&lt;p&gt;Two-player local games had a property that made me confident the niche was worth building around: the use case is social. When someone searches for "two player games," they're almost always doing it with another person present. That social context creates return behavior. Players don't just bookmark a game — they bookmark a destination to come back to with whoever they play with. The retention mechanic is built into the situation.&lt;/p&gt;

&lt;p&gt;The competitive landscape also mattered. At the time, no one had built a destination site specifically for this query cluster. The keyword had volume, clear intent, an underserved audience, and no direct competitor. That combination doesn't appear often.&lt;/p&gt;

&lt;p&gt;How the keyword shaped the site architecture&lt;br&gt;
Once I committed to the niche, the keyword research informed decisions that went well beyond SEO.&lt;/p&gt;

&lt;p&gt;The site name came directly from the primary keyword: 2playerfun.com. Exact match domains have diminished in SEO value since the early days, but for a niche portal, having the keyword in the domain still serves as a clear signal to both users and search engines about what the site is.&lt;/p&gt;

&lt;p&gt;The category structure followed the keyword cluster. Instead of organizing by genre — action, puzzle, sports — I organized around play patterns: games for two on one keyboard, games for two on one screen, competitive games, cooperative games. The taxonomy reflected how the audience was actually thinking about their need.&lt;/p&gt;

&lt;p&gt;Every page title, meta description, and H1 was written with the primary and secondary keywords in mind. Not keyword stuffing — the content had to actually serve the user — but deliberate alignment between what people were searching for and what the pages delivered.&lt;/p&gt;

&lt;p&gt;The broader lesson&lt;/p&gt;

&lt;p&gt;The standard advice for niche sites is "find your niche." What that advice usually means in practice is: look at what you're interested in and find a corner of it that isn't overcrowded.&lt;/p&gt;

&lt;p&gt;That's backwards. Start with the keyword data. Find queries with real volume and weak competition. Then ask whether you can build something genuinely useful for the person behind that search.&lt;/p&gt;

&lt;p&gt;The niche doesn't come from your interests. It comes from the gap between what people are searching for and what currently exists to serve them.&lt;/p&gt;

&lt;p&gt;Two player games was that gap. The keyword research found it. The site filled it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://2playerfun.com" rel="noopener noreferrer"&gt;2playerfun.com&lt;/a&gt; is live now if you want to see where that keyword research ended up.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Why I Renamed My Game from Star Guardian to Cosmic Summon</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Tue, 31 Mar 2026 13:20:58 +0000</pubDate>
      <link>https://dev.to/imagebear/why-i-renamed-my-game-from-star-guardian-to-cosmic-summon-5eee</link>
      <guid>https://dev.to/imagebear/why-i-renamed-my-game-from-star-guardian-to-cosmic-summon-5eee</guid>
      <description>&lt;p&gt;Naming a game is harder than building it. I learned this the expensive way.&lt;/p&gt;

&lt;p&gt;Where Star Guardian came from&lt;/p&gt;

&lt;p&gt;When I started designing the gacha tower defense game that would eventually become Cosmic Summon, I had a working title almost immediately: Star Guardian. It felt right. The game is set against a backdrop of real constellations, the heroes are cosmic-themed, and the enemies arrive in waves named after star patterns. Guardian captured the defensive nature of tower defense. Star captured the visual identity.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftud39qvi4ruqo6vo1870.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.amazonaws.com%2Fuploads%2Farticles%2Ftud39qvi4ruqo6vo1870.jpg" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I built the entire game under that name. The internal project files are still called Star Guardian. The early design documents reference Star Guardian throughout. For months, that was the game.&lt;/p&gt;

&lt;p&gt;The problem&lt;/p&gt;

&lt;p&gt;When I went to submit to the App Store, I searched the name.&lt;br&gt;
Star Guardian was taken. Not just taken — it was occupied by an established title with a significant player base. Apple's App Store has strict policies around names that are identical or confusingly similar to existing apps. Submitting under Star Guardian wasn't just a branding risk; it was a rejection waiting to happen.&lt;/p&gt;

&lt;p&gt;This is a mistake I should have caught earlier. Checking name availability across the App Store, Google Play, and major trademark databases should be one of the first steps in any game project, not something you verify at submission time. I knew this. I skipped it anyway because the name felt so obviously mine that I didn't think to check.&lt;/p&gt;

&lt;p&gt;Finding a new name&lt;/p&gt;

&lt;p&gt;Renaming a game late in development is disorienting. The name you've been using becomes load-bearing — it's in your mental model of the project, in your marketing copy, in the visual identity you've been building. Changing it feels like changing something fundamental about what the game is.&lt;/p&gt;

&lt;p&gt;The criteria I worked from: the new name had to reference the cosmic setting, had to be available across all major platforms, and had to be distinct enough that a player searching for it would find exactly one result.&lt;/p&gt;

&lt;p&gt;I went through a long list. Cosmic Defender was taken. Star Sentinel was too generic. Constellation Wars felt like a different genre entirely.&lt;/p&gt;

&lt;p&gt;Cosmic Summon worked. Cosmic kept the setting. Summon captured the gacha mechanic that sits at the center of the game loop — you summon heroes, you place them, you merge duplicates to evolve them into higher rarity tiers. The name describes what you do, not just where you do it. And it was available.&lt;/p&gt;

&lt;p&gt;What the rename cost&lt;/p&gt;

&lt;p&gt;The practical cost was less than I expected. The game's visual identity — constellation backdrops, cosmic hero designs, the color palette — didn't need to change. The marketing copy needed rewriting. The App Store listing, the game's landing page, the description on phyfun.com — all of it needed updating before submission.&lt;/p&gt;

&lt;p&gt;The psychological cost was higher. There's a version of this game called Star Guardian that exists only in my project files, and it takes a conscious effort not to revert to that name when describing the game to people who knew the earlier name.&lt;/p&gt;

&lt;p&gt;What the game actually is&lt;/p&gt;

&lt;p&gt;Cosmic Summon is a gacha merge tower defense game. Players summon heroes using a gacha system, place them on the battlefield, and merge identical units to evolve them through 7 rarity tiers — from Spark up to the legendary Genesis rank. The game features 12 distinct heroes, 28 enemy types with unique abilities, and 50 waves set against real constellation backdrops from Aries to the Southern Cross.&lt;/p&gt;

&lt;p&gt;The browser version is live now at &lt;a href="https://phyfun.com/game/cosmic-summon-tower-defense-27195" rel="noopener noreferrer"&gt;phyfun.com&lt;/a&gt;. The iOS version is currently in App Store review.&lt;/p&gt;

&lt;p&gt;The lesson&lt;/p&gt;

&lt;p&gt;Check name availability before you fall in love with a name. Search the App Store, Google Play, the USPTO trademark database, and do a basic web search. Do this on day one of the project, not day one hundred.&lt;/p&gt;

&lt;p&gt;The rename worked out. Cosmic Summon is a better descriptive name than Star Guardian was — it tells you more about the mechanics in two words. But I got lucky that the change was mostly cosmetic. If the name had been embedded in the game's UI, in level dialogue, in audio files, the cost would have been significantly higher.&lt;/p&gt;

&lt;p&gt;Name your game last. Or at least hold it loosely until you've shipped.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>learning</category>
      <category>gamedev</category>
      <category>development</category>
    </item>
  </channel>
</rss>
