<?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>I Rewrote One AI Prompt 15 Times to Kill the "AI Smell" in Product Reviews</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Fri, 05 Jun 2026 03:43:36 +0000</pubDate>
      <link>https://dev.to/imagebear/i-rewrote-one-ai-prompt-15-times-to-kill-the-ai-smell-in-product-reviews-3cj4</link>
      <guid>https://dev.to/imagebear/i-rewrote-one-ai-prompt-15-times-to-kill-the-ai-smell-in-product-reviews-3cj4</guid>
      <description>&lt;p&gt;You can smell an AI-written product review from the first sentence. "In today's fast-paced world, finding the right product can be a game-changer." Nobody talks like that. Nobody who has actually used a slow-feeder bowl for three months opens with a thesis statement.&lt;/p&gt;

&lt;p&gt;I run a pet-gear review site, and I wanted to use AI to speed up drafting without producing that. What I found is that the prompt is the entire job. I ended up rewriting one review-drafting prompt around fifteen times, and the interesting part wasn't getting the AI to write &lt;em&gt;more&lt;/em&gt; — it was getting it to write &lt;em&gt;less like an AI&lt;/em&gt;. Here's what actually moved the needle, and the hard ceiling I kept hitting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the first prompt failed
&lt;/h2&gt;

&lt;p&gt;The naive version is the one everyone starts with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Write a review of [product]. Make it sound natural and helpful.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What comes back is fluent and completely hollow. It has the &lt;em&gt;shape&lt;/em&gt; of a review with none of the substance: sweeping adjectives, zero specifics, a conclusion that could apply to any product in the category. "Make it sound natural" does nothing, because the model's default register already &lt;em&gt;is&lt;/em&gt; what it thinks natural sounds like — and its idea of natural is a marketing brochure.&lt;/p&gt;

&lt;p&gt;The problem isn't fluency. The model has plenty. The problem is that generic prose is its lowest-energy output, and a vague prompt lets it sit there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually worked
&lt;/h2&gt;

&lt;p&gt;The fixes were all about removing the model's room to be generic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A banned-phrase list.&lt;/strong&gt; I keep an explicit blocklist in the prompt — "game-changer," "in today's world," "look no further," "when it comes to," "elevate your," "whether you're a... or a..." Naming the clichés is far more effective than asking for "no clichés," because the model can't reliably detect its own tells; it can follow a literal list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forced structure with required slots.&lt;/strong&gt; Instead of "write a review," the prompt demands specific components: the use case it's actually for, who it's &lt;em&gt;not&lt;/em&gt; for, and at least one concrete drawback. The single biggest jump in believability came from forcing a real negative. Reviews with no downside read as ads; humans instinctively distrust them. A required "here's what annoyed me" slot breaks the brochure tone instantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Specificity over judgment.&lt;/strong&gt; I forbid bare evaluations. The model can't write "it works great" — it has to write &lt;em&gt;under what conditions&lt;/em&gt; it behaves how. "Great for a strong puller" is a claim; "it works great" is filler. Forcing the conditional drags the text toward something testable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Constrained voice and density.&lt;/strong&gt; First person, restrained, no encyclopedia tone, no summarizing the entire product category before getting to the point. I cap how much throat-clearing it's allowed before the first concrete observation.&lt;/p&gt;

&lt;p&gt;Each of these is a small rule. Stacked together, they pull the output out of brochure-land and into something that at least reads like a person wrote it.&lt;/p&gt;

&lt;p&gt;(For the mechanical drafting I leaned on Claude — it's good at following a strict, rule-heavy prompt like this. The prompt design was the work; the generation was the easy part.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The ceiling no prompt gets past
&lt;/h2&gt;

&lt;p&gt;Here's where I have to be honest, because it's the whole point.&lt;/p&gt;

&lt;p&gt;Every technique above improves &lt;em&gt;how the review reads&lt;/em&gt;. None of them improves &lt;em&gt;whether it's true&lt;/em&gt;. You can make AI text sound like a person who used the product for three months. You cannot make it into a person who used the product for three months.&lt;/p&gt;

&lt;p&gt;The things that make a review actually worth reading — how it held up after real use, the specific way it failed that the product page never mentions, the exact measurement, the photo of the thing dirty and dented on your kitchen floor — the model doesn't have any of that. If you let it fill those gaps, it invents them, and now you're not writing a thin review, you're writing a confidently false one. That's worse.&lt;/p&gt;

&lt;p&gt;So the line I settled on: AI drafts and structures; a human supplies the experience. The prompt produces a clean, specific, human-sounding &lt;em&gt;skeleton&lt;/em&gt;. Then someone who has actually handled the product fills in the parts only use can produce — and cuts anything the draft asserted that isn't true. On a pet site especially, where people make decisions that affect an animal's health, getting that division wrong isn't a style problem.&lt;/p&gt;

&lt;p&gt;This also happens to line up with where search is going. Google's "helpful content" push is explicitly hunting for first-hand experience and demoting content that's generated to rank. A pile of fluent, experience-free reviews is exactly the target. The fluency was never the moat. The experience is.&lt;/p&gt;

&lt;p&gt;That's the workflow behind &lt;a href="https://pawpry.com" rel="noopener noreferrer"&gt;pawpry.com&lt;/a&gt; — AI for the draft, a real person for the parts that matter. The prompt engineering got me a better starting line, not a finish line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If you're using AI to draft content, spend your effort on the prompt, not on editing the output into shape afterward — a good prompt prevents the AI smell, late editing only masks it. But know exactly what the prompt can and can't buy you. It buys readability. It does not buy experience, and the moment you ask it to fake experience, you've built the thing everyone — readers and search engines alike — is learning to filter out.&lt;/p&gt;




&lt;p&gt;Solo indie dev, writing these up as I go. If you've found prompt tricks that kill the AI tone, I'll take them — and I'm curious where everyone else draws the human-in-the-loop line.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>writing</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
    <item>
      <title>From ASP.NET + MSSQL to PHP + MySQL: Migrating a Names Site Without Mangling the Accents</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Sat, 30 May 2026 13:05:41 +0000</pubDate>
      <link>https://dev.to/imagebear/from-aspnet-mssql-to-php-mysql-migrating-a-names-site-without-mangling-the-accents-52dj</link>
      <guid>https://dev.to/imagebear/from-aspnet-mssql-to-php-mysql-migrating-a-names-site-without-mangling-the-accents-52dj</guid>
      <description>&lt;p&gt;My baby-name site started life on ASP.NET with a MSSQL backend. It worked. The reason I rewrote the whole thing in PHP + MySQL had nothing to do with the code being bad — it was everything around the code.&lt;/p&gt;

&lt;p&gt;This is the migration writeup: why I left a stack that was technically fine, the MSSQL → MySQL gotchas that actually cost me time (the worst one was thematically perfect for a &lt;em&gt;names&lt;/em&gt; site), and what the new stack let me build that the old one was quietly blocking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real reason: hosting, not code
&lt;/h2&gt;

&lt;p&gt;ASP.NET is a perfectly good framework. The problem is its habitat. For a solo dev shipping small sites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows hosting is scarce and pricier.&lt;/strong&gt; The cheap, plentiful end of the hosting market is overwhelmingly Linux/LAMP. Windows + IIS plans are fewer, cost more, and the budget ones I could find were the least stable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The servers were worse.&lt;/strong&gt; More downtime, slower support, fewer knobs I was allowed to touch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changes were awkward.&lt;/strong&gt; Deploying and tweaking a .NET app on shared Windows hosting was consistently more friction than &lt;code&gt;scp&lt;/code&gt; + a PHP file on a LAMP box.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PHP + MySQL inverts all three: hosting is everywhere, it's cheap, the quality bar at the same price is higher, and extending the site later is far less ceremony. For a content/data site I plan to keep adding to, "easy to extend on cheap, stable hosting" beats "technically elegant on expensive, fragile hosting" every time.&lt;/p&gt;

&lt;p&gt;So the port was a means to an end. The end was the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The schema migration: MSSQL → MySQL
&lt;/h2&gt;

&lt;p&gt;A names site is mostly database. The dictionary is ~19,862 names; the popularity layer is 63,890 US Social Security Administration records spanning 1880–2024; the geographic layer is ~6.5M state-level rows. Porting that is where the work lived.&lt;/p&gt;

&lt;p&gt;The type and syntax mapping that mattered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MSSQL                      -&amp;gt;  MySQL
-------------------------------------------------------
INT IDENTITY(1,1)          -&amp;gt;  INT AUTO_INCREMENT
NVARCHAR(n)                -&amp;gt;  VARCHAR(n)        (utf8mb4)
NVARCHAR(MAX)              -&amp;gt;  TEXT / LONGTEXT
BIT                        -&amp;gt;  TINYINT(1)
DATETIME2                  -&amp;gt;  DATETIME
GETDATE()                  -&amp;gt;  NOW()
ISNULL(x, y)               -&amp;gt;  IFNULL(x, y) / COALESCE
LEN()                      -&amp;gt;  CHAR_LENGTH()
[bracketed].[columns]      -&amp;gt;  `backticked`.`columns`
SELECT TOP 10 ...          -&amp;gt;  SELECT ... LIMIT 10
... OFFSET n FETCH m       -&amp;gt;  ... LIMIT m OFFSET n
a + b   (string concat)    -&amp;gt;  CONCAT(a, b)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these is hard individually. The cost is that they're scattered through every query and stored procedure, so a "find and replace" mindset misses things — especially string concatenation with &lt;code&gt;+&lt;/code&gt;, which silently becomes numeric addition in MySQL instead of erroring.&lt;/p&gt;

&lt;h2&gt;
  
  
  The collation trap (where the accents died)
&lt;/h2&gt;

&lt;p&gt;Here's the one that bit me, and it's almost funny that it happened on a &lt;em&gt;names&lt;/em&gt; site of all things.&lt;/p&gt;

&lt;p&gt;Names are full of non-ASCII characters: José, Zoë, Renée, François, Søren. The original MSSQL database stored these fine under its own collation. When I did the first bulk import into MySQL, a chunk of names came back as &lt;code&gt;Jos?&lt;/code&gt;, &lt;code&gt;Ren?e&lt;/code&gt;, or worse — mojibake like &lt;code&gt;JosÃ©&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two separate things were wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The column character set.&lt;/strong&gt; Defaulting to &lt;code&gt;utf8&lt;/code&gt; in MySQL is a trap — historical MySQL &lt;code&gt;utf8&lt;/code&gt; is only 3 bytes and can't store the full Unicode range. The correct choice is &lt;code&gt;utf8mb4&lt;/code&gt; with a &lt;code&gt;utf8mb4_unicode_ci&lt;/code&gt; (or &lt;code&gt;0900_ai_ci&lt;/code&gt;) collation, end to end: table, connection, and client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The import encoding.&lt;/strong&gt; Exporting from MSSQL and loading into MySQL without pinning the encoding on both sides re-interpreted the bytes. The fix was exporting as UTF-8 explicitly and telling &lt;code&gt;LOAD DATA INFILE&lt;/code&gt; the same with &lt;code&gt;CHARACTER SET utf8mb4&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The lesson I'd tattoo on past-me: set &lt;code&gt;utf8mb4&lt;/code&gt; on the database, the table, &lt;em&gt;and&lt;/em&gt; the connection (&lt;code&gt;SET NAMES utf8mb4&lt;/code&gt; / PDO &lt;code&gt;charset=utf8mb4&lt;/code&gt;) before importing a single row. Fixing encoding after the data's already mangled means re-importing from source, because you can't always tell a correctly-stored &lt;code&gt;é&lt;/code&gt; from a double-encoded one after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving 6.5M rows
&lt;/h2&gt;

&lt;p&gt;For the bulk data — especially the state-level records — row-by-row inserts were a non-starter. The path that worked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Export each MSSQL table to UTF-8 CSV.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LOAD DATA INFILE&lt;/code&gt; into MySQL with the charset pinned, indexes added &lt;em&gt;after&lt;/em&gt; the load (building indexes during a multi-million-row insert is dramatically slower).&lt;/li&gt;
&lt;li&gt;Spot-check a sample of accented names against the source before trusting the whole load.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The app layer
&lt;/h2&gt;

&lt;p&gt;The PHP rewrite itself was the least surprising part. Data access went from ADO.NET to PDO with prepared statements (parameterized everywhere — a data site is a giant SQL-injection surface if you're lazy). Paging went from &lt;code&gt;OFFSET/FETCH&lt;/code&gt; to &lt;code&gt;LIMIT ... OFFSET&lt;/code&gt;. The routing and templating got rebuilt but conceptually mapped one-to-one.&lt;/p&gt;

&lt;p&gt;I'll be honest that I leaned on Claude to accelerate the mechanical parts of the port — translating stored-procedure logic and grinding through the query rewrites. It's good at the tedious 1:1 translation; the judgment calls (the collation decision, the index-after-load ordering) were still mine to get wrong first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the new stack unlocked
&lt;/h2&gt;

&lt;p&gt;Here's the payoff, and the reason the migration was worth it. On the old stack, every new feature was a fight with the hosting. On PHP + MySQL, adding to the site got cheap enough that I could turn a static name dictionary into something closer to a data product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Query-driven tools&lt;/strong&gt; straight off the SSA tables — popularity by birth year, year-over-year trending movers, a name → US-state lookup over those 6.5M rows, side-by-side comparison of two names across 145 years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Research reports&lt;/strong&gt; generated from the same dataset — decade-by-decade breakdowns, per-state rankings, long-arc analyses like which mid-century names have effectively gone extinct.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that was &lt;em&gt;impossible&lt;/em&gt; on the old stack. It was just expensive and annoying enough that I never did it. That's the quiet cost of a high-friction stack: not the features you can't build, but the ones you don't bother to.&lt;/p&gt;

&lt;p&gt;You can see where it landed at &lt;a href="https://9babynames.com" rel="noopener noreferrer"&gt;9babynames.com&lt;/a&gt;. The migration is invisible to visitors — which is exactly the point.&lt;/p&gt;




&lt;p&gt;Solo indie dev, writing these up as I go. If you've done a MSSQL → MySQL move, I'm curious whether the collation/charset step bit you too, or whether I just walked into it.&lt;/p&gt;

</description>
      <category>php</category>
      <category>mysql</category>
      <category>dotnet</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Cloned My Dog-Name Site to Build a Cat-Name Site. The Routing Layer Bit Back.</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Wed, 27 May 2026 07:36:06 +0000</pubDate>
      <link>https://dev.to/imagebear/i-cloned-my-dog-name-site-to-build-a-cat-name-site-the-routing-layer-bit-back-28fg</link>
      <guid>https://dev.to/imagebear/i-cloned-my-dog-name-site-to-build-a-cat-name-site-the-routing-layer-bit-back-28fg</guid>
      <description>&lt;p&gt;I run a dog-name site. A few weeks ago a reader emailed asking the obvious question: "Do you have one for cats?"&lt;/p&gt;

&lt;p&gt;I didn't. But I had a fully built PHP content site sitting right there — browse names by breed, by origin, by color, by size; a name-detail page for every entry; clean URLs all the way down. Cats are just dogs with a different attitude and a smaller breed list, right? I figured I'd clone the whole thing in an afternoon.&lt;/p&gt;

&lt;p&gt;It did not take an afternoon. And the part that ate the time wasn't the data or the templates — it was the routing layer. This is the story of what doesn't copy over when you "just clone" a content site, written down so the next person (or future me) doesn't repeat it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the thing
&lt;/h2&gt;

&lt;p&gt;Both sites are plain PHP — no framework. Every page is a file under &lt;code&gt;pages/&lt;/code&gt;, and a clean URL gets rewritten to it with a query string. So &lt;code&gt;/breeds/letter/a/page/2&lt;/code&gt; becomes &lt;code&gt;pages/breeds.php?letter=a&amp;amp;page=2&lt;/code&gt;. Nice URLs for users and search engines, ordinary PHP underneath.&lt;/p&gt;

&lt;p&gt;The catch is that the rewrite has to happen in &lt;strong&gt;two completely different places&lt;/strong&gt;, because the site runs in two completely different environments.&lt;/p&gt;

&lt;p&gt;In production it's Apache, so the rules live in &lt;code&gt;.htaccess&lt;/code&gt;:&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;# /breeds, /breeds/letter/{a}, /breeds/page/{n}&lt;/span&gt;
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^breeds(?:/letter/([a-z]))?(?:/page/(\d+))?/?$ pages/breeds.php?letter=$1&amp;amp;page=$2 [L,QSA,NC]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Locally I develop with PHP's built-in server (&lt;code&gt;php -S localhost:8080 router.php&lt;/code&gt;), which knows nothing about &lt;code&gt;.htaccess&lt;/code&gt;. So the &lt;em&gt;same&lt;/em&gt; route has to exist again, as a &lt;code&gt;preg_match&lt;/code&gt; in &lt;code&gt;router.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'#^/breeds(?:/letter/([a-z]))?(?:/page/(\d+))?/?$#i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$_GET&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'letter'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$_GET&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'page'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;require&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/pages/breeds.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One logical route, two physical definitions. On a single site that's already a low-grade tax: every time I add a route I edit two files and keep two regexes in sync. It works, I'd made my peace with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloning doubled the tax into something worse
&lt;/h2&gt;

&lt;p&gt;Here's the thing I didn't think through before I copied the folder. The site has dozens of routes — name detail, categories, the dictionary, search, comparison pages, a pile of 301s for legacy URLs. Each one already lived in two places. Clone the site, and now &lt;strong&gt;every route lives in four places, across two repos.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first time I added a cat-specific route, I added it to the cat site's &lt;code&gt;.htaccess&lt;/code&gt;, tested it on production, and it worked. Then a week later I went to test something locally and got a 404 — because I'd never mirrored that rule into the cat site's &lt;code&gt;router.php&lt;/code&gt;. The local and production routing had silently drifted apart. On the dog site I'd trained myself to edit both. On the fresh clone, I hadn't built that muscle yet, and the duplication just quietly rotted.&lt;/p&gt;

&lt;p&gt;That's the trap with cloning: you copy the code, but you don't copy the &lt;em&gt;discipline&lt;/em&gt; the code silently depends on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually didn't transfer
&lt;/h2&gt;

&lt;p&gt;Beyond routing, a few assumptions I'd hardcoded for dogs leaked through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Breed counts.&lt;/strong&gt; The dog site assumes a long breed list with its own pagination thresholds. Cats have far fewer recognized breeds, so pages that were comfortably full on one site looked empty on the other. The layout "worked" but felt broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Category vocabulary.&lt;/strong&gt; "Working dogs," "herding," "guardian" — none of that maps to cats. The category system was generic enough to &lt;em&gt;run&lt;/em&gt; with cat data, but the seed categories were dog-shaped, so half of them were nonsense until I redid them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy and microcopy.&lt;/strong&gt; Every "your new puppy" string. Cloning finds these the hard way, one user-facing typo at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these were hard. They were just invisible until real data hit them — which is exactly the kind of thing a clone hides from you, because the code compiles and the page renders. It's the &lt;em&gt;content&lt;/em&gt; that's wrong, and content doesn't throw exceptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do instead (and am slowly refactoring toward)
&lt;/h2&gt;

&lt;p&gt;The honest takeaway: I shouldn't have cloned a site. I should have built &lt;strong&gt;one engine and two instances.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Concretely, the routing table should be defined &lt;em&gt;once&lt;/em&gt;, as data, not twice as code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// routes.php — single source of truth&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="s1"&gt;'pattern'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'#^/breeds(?:/letter/([a-z]))?(?:/page/(\d+))?/?$#i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="s1"&gt;'page'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'breeds.php'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="s1"&gt;'params'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'letter'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'page'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;router.php&lt;/code&gt; loops over that array for local dev. And a small build script can generate the &lt;code&gt;.htaccess&lt;/code&gt; rewrite block from the same table for production. One definition, two consumers — instead of four hand-maintained copies. The entity ("dog" vs "cat") becomes a config value: breed list, categories, and copy strings get injected, while the engine stays identical.&lt;/p&gt;

&lt;p&gt;I'm not all the way there yet — the cat site shipped on the cloned codebase because shipping beat refactoring. But every route I touch now, I move into the shared table instead of duplicating it. The fork is slowly converging back into an engine.&lt;/p&gt;

&lt;p&gt;If you want to see the two instances side by side, the cat site is live at &lt;a href="https://icatnames.com" rel="noopener noreferrer"&gt;icatnames.com&lt;/a&gt; and the original dog site it grew out of is &lt;a href="https://idognames.com" rel="noopener noreferrer"&gt;idognames.com&lt;/a&gt; — same bones, different animal, and (now) a slowly shrinking amount of duplicated routing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson in one line
&lt;/h2&gt;

&lt;p&gt;Cloning copies your code but not the invisible discipline it relies on. If a site has any duplicated-but-must-stay-in-sync layer, a clone doesn't double your maintenance — it squares it.&lt;/p&gt;

&lt;p&gt;How do the rest of you handle dev-vs-prod routing parity without a framework? I'd genuinely like to hear if there's a cleaner pattern than "generate one from the other."&lt;/p&gt;

</description>
      <category>php</category>
      <category>webdev</category>
      <category>seo</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>Cocos Creator 2D Physics on iOS: Notes on Fixed Timestep, ProMotion, and CCD</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Sun, 17 May 2026 09:56:26 +0000</pubDate>
      <link>https://dev.to/imagebear/cocos-creator-2d-physics-on-ios-notes-on-fixed-timestep-promotion-and-ccd-4j52</link>
      <guid>https://dev.to/imagebear/cocos-creator-2d-physics-on-ios-notes-on-fixed-timestep-promotion-and-ccd-4j52</guid>
      <description>&lt;p&gt;Cocos Creator's 2D physics looks solid in editor preview. Bouncing balls behave. Collisions trigger. Frame rate sits at 60. Then you ship to iOS, install on an actual device, and small things start to feel off — a ball that occasionally tunnels through a thin wall, gameplay that feels subtly faster on newer iPhones, a stuck collision after the user backgrounds the app and reopens it.&lt;/p&gt;

&lt;p&gt;None of these are obvious bugs. They're the result of how the engine's physics interacts with iOS-specific runtime behavior that doesn't show up in desktop testing.&lt;/p&gt;

&lt;p&gt;After shipping a 2D physics-based ricochet game built on Cocos Creator 3.x to the App Store, here's the short list of things I'd configure differently if I were starting over.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. ProMotion silently changes your simulation
&lt;/h2&gt;

&lt;p&gt;iPhone 13 Pro was the first iPhone with ProMotion, and every Pro model since has it. ProMotion means the display refreshes at up to 120Hz. WebViews and native runtime loops driven by &lt;code&gt;CADisplayLink&lt;/code&gt; follow that refresh rate, which means &lt;code&gt;requestAnimationFrame&lt;/code&gt; — and the Cocos Creator game loop bound to it — can fire up to 120 times per second on these devices.&lt;/p&gt;

&lt;p&gt;If your physics integration uses the per-frame delta time directly — &lt;code&gt;velocity += accel * dt; position += velocity * dt&lt;/code&gt; — your simulation now runs at a different effective resolution on a 120Hz device than on a 60Hz device. Same starting state, same input, slightly different physical outcome. Players on newer iPhones experience a subtly different game.&lt;/p&gt;

&lt;p&gt;The fix is to lock physics to a fixed timestep, decoupled from rendering. In Project Settings → Physics 2D, set the Fixed Time Step explicitly (1/60s is the standard choice):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PhysicsSystem2D&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;PhysicsSystem2D&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fixedTimeStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rendering still happens at whatever rate the device offers. Physics ticks at 60Hz regardless. Behavior becomes consistent across devices.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Don't integrate with variable dt
&lt;/h2&gt;

&lt;p&gt;Even on a constant 60Hz display, the actual frame interval can vary — a thermal-throttled iPhone can drop to 45fps mid-game, or hit 30fps if other apps are competing for CPU. The classic accumulator pattern handles this without breaking the simulation:&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;FIXED_DT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;accumulator&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;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deltaTime&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="nx"&gt;accumulator&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;deltaTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accumulator&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;FIXED_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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stepPhysics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FIXED_DT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;accumulator&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;FIXED_DT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// Optional: interpolate render position using accumulator / FIXED_DT&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cocos Creator's built-in physics system does this internally once you configure a fixed time step. But if you're running any of your own integration — custom forces, custom motion on non-rigidbody entities, gameplay logic that touches positions — apply the same pattern there. The moment any part of your simulation uses raw &lt;code&gt;deltaTime&lt;/code&gt;, you've reintroduced the inconsistency you just fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. CCD is not on by default, and you usually want it
&lt;/h2&gt;

&lt;p&gt;A ball moving at 1000 px/s in a 60Hz simulation moves about 16.7 px per frame. If your wall collider is 10 px thick — or your ball is small and your wall is at an angle — there's a real chance the ball is &lt;em&gt;in front of&lt;/em&gt; the wall on frame N and &lt;em&gt;behind&lt;/em&gt; it on frame N+1, with no collision detected in between.&lt;/p&gt;

&lt;p&gt;Discrete collision detection misses these. The fix is continuous collision detection, which in box2d terms means setting the bullet flag on the rigid body:&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;rb&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;getComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;RigidBody2D&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;rb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bullet&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CCD has a real CPU cost. Apply it only to bodies that actually move fast — typically the player projectile, not every dynamic body in the scene.&lt;/li&gt;
&lt;li&gt;It only protects against passing through static and kinematic bodies. Two CCD-enabled dynamic bodies can still miss each other under box2d's defaults.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have geometry that's both fast-moving and dynamic-on-dynamic, supplement with manual raycasts between frames. Sample the start and end positions, fire a raycast along that segment, snap to the first hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Touch input lands later than you think
&lt;/h2&gt;

&lt;p&gt;A user taps the screen. The native touch event arrives at the WebView. WebView forwards it to JavaScript. Your input handler runs. Cocos Creator's event system dispatches it on the next tick. Your physics responds on the tick after that.&lt;/p&gt;

&lt;p&gt;That's two to three frames of latency between the finger landing and the ball reacting. At 60Hz that's 33-50ms — noticeable in a precision physics game where the player is reading visual feedback to aim.&lt;/p&gt;

&lt;p&gt;You can't eliminate the WebView → JS hop, but you can avoid adding more delay:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don't queue input into a buffer that's drained on the next physics tick. Handle it in the same tick if the input affects current-frame simulation.&lt;/li&gt;
&lt;li&gt;For aiming-style mechanics, render a preview of the trajectory the moment the touch starts, and only commit to a physics force on release. The visual responsiveness masks the input lag — by the time the player releases, they've already been seeing what would happen.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Background/foreground destroys your physics state
&lt;/h2&gt;

&lt;p&gt;When the user switches to another app and comes back, the JS event loop has been suspended for an arbitrary duration — seconds, minutes, hours. The next &lt;code&gt;update()&lt;/code&gt; call receives a delta time that reflects that entire pause.&lt;/p&gt;

&lt;p&gt;If you pass that delta unfiltered into your physics step, the accumulator from section 2 runs hundreds or thousands of fixed steps in a single tick. The game freezes for a moment, then resumes with the ball teleported across the level — through walls, past triggers, anywhere.&lt;/p&gt;

&lt;p&gt;Two complementary fixes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Clamp delta to prevent runaway accumulation&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;deltaTime&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;safeDt&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deltaTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ... use safeDt for physics&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pause physics on visibility change&lt;/span&gt;
&lt;span class="nb"&gt;document&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;visibilitychange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;PhysicsSystem2D&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&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 clamp protects you from runaway dt. The &lt;code&gt;visibilitychange&lt;/code&gt; handler is cleaner — physics genuinely shouldn't tick when the user can't see the game. Use both.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Determinism is fragile, and you probably don't need it
&lt;/h2&gt;

&lt;p&gt;If you ever want replay validation, network sync, or "the same level always plays out the same given the same input," you need deterministic physics. Floating-point math across iOS device generations — and especially across iOS vs Android — is not guaranteed to produce bit-identical results, even with a fixed timestep.&lt;/p&gt;

&lt;p&gt;This is solvable: fixed-point math, integer-only simulation, or running physics on a server. Each path is heavyweight.&lt;/p&gt;

&lt;p&gt;For most single-player physics games, the right answer is to not need determinism. Save state snapshots instead of replays. Validate completion server-side based on outcomes (level cleared, score reached) rather than exact motion paths. Build replay sharing as a video, not a deterministic simulation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-ship checklist
&lt;/h2&gt;

&lt;p&gt;Before submitting a Cocos Creator 2D physics game to App Review, I'd now check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Physics fixed timestep is set explicitly, not left to default&lt;/li&gt;
&lt;li&gt;Fast-moving rigid bodies have &lt;code&gt;bullet = true&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Input handlers process within the same tick as the affected physics step&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deltaTime&lt;/code&gt; is clamped, and physics pauses on &lt;code&gt;visibilitychange&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Tested on at least one ProMotion device (iPhone 13 Pro or later) and one older device&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is obscure. It's just easy to miss when desktop preview looks perfect.&lt;/p&gt;




&lt;p&gt;The game these notes come from is Juicy Ricochet — playable in the browser at &lt;a href="https://phyfun.com/game/juicy-ricochet-26888" rel="noopener noreferrer"&gt;phyfun.com&lt;/a&gt; or as a native iOS build on the &lt;a href="https://apps.apple.com/app/id6755329652" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;. Most of what's above is the result of behavior I didn't see until I was running on a real iPhone in real hands.&lt;/p&gt;

</description>
      <category>cocos</category>
      <category>gamedev</category>
      <category>ios</category>
      <category>javascript</category>
    </item>
    <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>
  </channel>
</rss>
