<?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.us-east-2.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>Building a browser-first PHP tools directory without pretending everything is offline</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Sun, 13 Sep 2026 11:55:05 +0000</pubDate>
      <link>https://dev.to/imagebear/building-a-browser-first-php-tools-directory-without-pretending-everything-is-offline-17hn</link>
      <guid>https://dev.to/imagebear/building-a-browser-first-php-tools-directory-without-pretending-everything-is-offline-17hn</guid>
      <description>&lt;p&gt;Kitset is a PHP-rendered directory that currently exposes 423 small browser tools. The interesting engineering problem was not adding one more calculator. It was deciding, for every tool, which work belongs on the server, which work belongs in the browser, and what the offline promise should actually mean.&lt;/p&gt;

&lt;p&gt;Here is the boundary I ended up using.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Let PHP own discovery, not the user's working data
&lt;/h2&gt;

&lt;p&gt;The server renders stable routes, titles, descriptions, categories, related tools, and the component configuration for each page. That gives every tool a predictable URL and keeps navigation usable before the interactive code mounts.&lt;/p&gt;

&lt;p&gt;The tool itself is selected by a small component key and a JSON configuration. On the client, a registry mounts the matching module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Kitset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gsc-workspaces&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;init&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;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&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;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seo-launch-kit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;seoWorkspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Kitset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;el&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 split matters. PHP does not need the title or meta description a visitor is drafting. It only needs to deliver the page and the code that can do the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Make privacy claims at tool level
&lt;/h2&gt;

&lt;p&gt;For the &lt;a href="https://kitset.app/seo-launch-kit" rel="noopener noreferrer"&gt;SEO Launch Kit&lt;/a&gt;, the browser reads four fields: page title, site name, page URL, and meta description. A click handler builds a SERP preview, a robots.txt starter, a sitemap entry, and a WebPage JSON-LD object. There is no request in that calculation path.&lt;/p&gt;

&lt;p&gt;The same pattern works for many text, calculation, and file utilities: use browser APIs, keep the working value in memory, and offer copy or download only after the result exists.&lt;/p&gt;

&lt;p&gt;But I avoid turning that into a blanket architecture claim. Some utilities may need a remote URL, an API route, or a browser capability that is unavailable on a particular device. The public rule is narrower: inputs stay on the device whenever the task allows it. Each tool still needs its own review.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Treat the service worker as a cache policy, not a magic offline switch
&lt;/h2&gt;

&lt;p&gt;Kitset precaches a small shell: the home page, the tools directory, My Kitset, core CSS and JavaScript, and icons. It does not precache 423 tool pages.&lt;/p&gt;

&lt;p&gt;The fetch handler also has explicit boundaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&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;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Static assets use a cached response when available while refreshing from the network. Navigations are network-first and fall back to a previously cached page, then to the home shell. That means an already visited route may remain useful during a connection failure. It does not mean the entire catalog is available on first install.&lt;/p&gt;

&lt;p&gt;This wording is less exciting than “works fully offline,” but it survives inspection.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Keep local persistence equally bounded
&lt;/h2&gt;

&lt;p&gt;Favorites, recent tools, and remembered setups use localStorage. The shared helper cleans URLs and limits stored collections. Result text is not sent through analytics; only an allowlist of aggregate event fields can reach gtag when it is present.&lt;/p&gt;

&lt;p&gt;LocalStorage is convenience, not durable storage. It may be unavailable, cleared, or shared with anyone using the same browser profile. The UI therefore treats saved state as optional and keeps copy/download as the explicit handoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist I now use
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Can the task run entirely with browser APIs? If not, name the remote dependency.&lt;/li&gt;
&lt;li&gt;Does any input cross the network? Verify the actual event path, not only the page copy.&lt;/li&gt;
&lt;li&gt;What is precached, runtime-cached, or deliberately bypassed?&lt;/li&gt;
&lt;li&gt;Does an offline claim describe a visited route or the whole product?&lt;/li&gt;
&lt;li&gt;What is kept in memory, localStorage, a download, or a server database?&lt;/li&gt;
&lt;li&gt;Can a user understand those boundaries without reading the source?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The main lesson is that “browser-first” is not one implementation technique. It is a collection of narrow, testable decisions. Server-rendered discovery, client-side work, bounded local persistence, and a conservative cache policy can coexist—as long as the product copy does not collapse them into a promise the code cannot keep.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>php</category>
      <category>javascript</category>
      <category>pwa</category>
    </item>
    <item>
      <title>A Good AI Workflow Brief Needs More Than a Prompt</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Tue, 08 Sep 2026 09:17:49 +0000</pubDate>
      <link>https://dev.to/imagebear/a-good-ai-workflow-brief-needs-more-than-a-prompt-1kbb</link>
      <guid>https://dev.to/imagebear/a-good-ai-workflow-brief-needs-more-than-a-prompt-1kbb</guid>
      <description>&lt;p&gt;Most AI workflow failures do not begin with a bad model response. They begin one step earlier, when a vague request is treated as a complete specification.&lt;/p&gt;

&lt;p&gt;“Summarize these notes.”&lt;/p&gt;

&lt;p&gt;“Research our competitors.”&lt;/p&gt;

&lt;p&gt;“Update the project.”&lt;/p&gt;

&lt;p&gt;Each sentence sounds actionable. None tells an AI system what evidence it may use, what it must not do, who reviews the result, or what would make the output acceptable.&lt;/p&gt;

&lt;p&gt;A prompt can describe a task. A workflow brief defines the operating boundary around that task.&lt;/p&gt;

&lt;p&gt;That distinction matters as soon as AI work touches real files, current facts, customer data, source citations, code, or external systems. In those settings, fluent output is not the same as a verified result.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt is only one layer
&lt;/h2&gt;

&lt;p&gt;A useful AI workflow has at least five controls:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A specific outcome&lt;/li&gt;
&lt;li&gt;An evidence boundary&lt;/li&gt;
&lt;li&gt;A data-safety boundary&lt;/li&gt;
&lt;li&gt;A named review owner&lt;/li&gt;
&lt;li&gt;Observable acceptance checks&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These controls do not make a model infallible. They make errors easier to detect and responsibility harder to blur.&lt;/p&gt;

&lt;p&gt;Consider this request:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Turn our meeting notes into a weekly project update.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An assistant could produce a polished update and still be wrong in several ways. It might infer that an unfinished feature shipped, silently combine old and new notes, omit an unresolved blocker, or phrase a proposal as an approved decision.&lt;/p&gt;

&lt;p&gt;The problem is not prose quality. The problem is that the request leaves critical decisions unstated.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Define the outcome, not just the activity
&lt;/h2&gt;

&lt;p&gt;“Summarize the notes” describes an activity. It does not describe the result someone needs.&lt;/p&gt;

&lt;p&gt;A stronger outcome might be:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Produce a weekly status update for the product lead that separates completed work, current blockers, decisions needed, and next-week commitments.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This version names the audience and the decisions the output should support. It also gives the reviewer a basis for rejecting material that is interesting but irrelevant.&lt;/p&gt;

&lt;p&gt;When writing the outcome, ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who will use the result?&lt;/li&gt;
&lt;li&gt;What decision or action should it support?&lt;/li&gt;
&lt;li&gt;What shape should the result have?&lt;/li&gt;
&lt;li&gt;What is explicitly outside scope?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Draw an evidence boundary
&lt;/h2&gt;

&lt;p&gt;An AI system needs to know whether it should use only supplied material, rely on general knowledge, or conduct fresh research.&lt;/p&gt;

&lt;p&gt;These are different operating modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Supplied sources only:&lt;/strong&gt; Appropriate when the output must remain traceable to a controlled set of documents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;General knowledge with citations:&lt;/strong&gt; Useful for background explanation, but claims that may have changed still need verification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fresh research required:&lt;/strong&gt; Appropriate when current facts matter and the workflow includes source collection and citation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Creative work with factual constraints:&lt;/strong&gt; Allows invention in the presentation while preserving named facts, claims, or brand rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without this boundary, a model may fill gaps with plausible material. That can be useful during brainstorming and dangerous in a status report, compliance review, or customer-facing document.&lt;/p&gt;

&lt;p&gt;Evidence rules should also say what to do when information is missing. “Mark as unknown and list the missing source” is usually safer than “make a reasonable assumption.”&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Name forbidden actions
&lt;/h2&gt;

&lt;p&gt;Constraints often describe formatting: word count, tone, language, or file type. A workflow also needs to identify actions that would create risk.&lt;/p&gt;

&lt;p&gt;Examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not claim that work was approved, deployed, sent, or published without direct evidence.&lt;/li&gt;
&lt;li&gt;Do not modify source files.&lt;/li&gt;
&lt;li&gt;Do not contact customers or teammates.&lt;/li&gt;
&lt;li&gt;Do not infer demographic or medical attributes.&lt;/li&gt;
&lt;li&gt;Do not include secrets, private keys, or personal identifiers in the output.&lt;/li&gt;
&lt;li&gt;Do not invent metrics to complete an empty field.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why state the obvious? Because an automated system is often rewarded for completing the apparent task. If “complete the report” conflicts with “leave unsupported fields blank,” the safer behavior must be explicit.&lt;/p&gt;

&lt;p&gt;A good forbidden-action list focuses on plausible shortcuts, not every imaginable mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Separate preparation from approval
&lt;/h2&gt;

&lt;p&gt;AI workflows often collapse three different states:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A draft exists.&lt;/li&gt;
&lt;li&gt;The draft passed checks.&lt;/li&gt;
&lt;li&gt;An authorized person approved its use.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those states are not interchangeable.&lt;/p&gt;

&lt;p&gt;For a code change, a patch can exist without tests passing. Tests can pass without a maintainer approving a merge. For an article, a draft can be complete without being published. For a business report, numbers can be checked without an executive approving the recommendation.&lt;/p&gt;

&lt;p&gt;The brief should name a review owner and define the approval boundary. “Reviewed by the product lead before distribution” is much clearer than “review before use.”&lt;/p&gt;

&lt;p&gt;Naming the role is usually enough. Naming the person is even better when the workflow is already assigned.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Use checks that can fail
&lt;/h2&gt;

&lt;p&gt;“High quality,” “accurate,” and “professional” sound like success criteria, but they are difficult to test.&lt;/p&gt;

&lt;p&gt;Useful acceptance checks are observable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every completed item links to an issue, pull request, or retained source note.&lt;/li&gt;
&lt;li&gt;Every metric includes its date range and source.&lt;/li&gt;
&lt;li&gt;Unresolved disagreements appear in a separate section.&lt;/li&gt;
&lt;li&gt;No item is labeled shipped unless release evidence is present.&lt;/li&gt;
&lt;li&gt;The final document stays under 600 words.&lt;/li&gt;
&lt;li&gt;A named reviewer signs off before distribution.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a check cannot fail, it probably will not control the workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  A compact workflow-brief schema
&lt;/h2&gt;

&lt;p&gt;The brief does not need to be long. It needs to make the important boundaries explicit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;goal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="s"&gt;Produce a weekly project update for the product lead,&lt;/span&gt;
  &lt;span class="s"&gt;separating completed work, blockers, decisions, and next steps.&lt;/span&gt;

&lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Product lead and engineering lead&lt;/span&gt;
&lt;span class="na"&gt;review_owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Project manager&lt;/span&gt;

&lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Approved meeting notes from this week&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Current issue tracker export&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Release log for the reporting period&lt;/span&gt;

&lt;span class="na"&gt;evidence_boundary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Use supplied sources only&lt;/span&gt;
&lt;span class="na"&gt;data_safety&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Public or approved non-sensitive project data&lt;/span&gt;
&lt;span class="na"&gt;consequence_level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Medium; review required before distribution&lt;/span&gt;

&lt;span class="na"&gt;forbidden_actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Do not infer completion from an optimistic comment&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Do not mark a feature shipped without release-log evidence&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Do not invent owners, dates, or metrics&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Do not send or publish the update&lt;/span&gt;

&lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Markdown&lt;/span&gt;
  &lt;span class="na"&gt;sections&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Completed&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Blocked&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Decisions needed&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Next week&lt;/span&gt;

&lt;span class="na"&gt;success_criteria&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Every completed item has a retained source reference&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Conflicting evidence is shown rather than silently resolved&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Unknown dates and owners remain explicitly unknown&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;The review owner approves the draft before distribution&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what is absent: elaborate role-play, motivational language, and a claim that one perfect prompt will solve the problem. The brief is closer to a small interface contract than a piece of persuasive writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  A six-question preflight
&lt;/h2&gt;

&lt;p&gt;Before running an AI workflow, answer these questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the desired outcome specific enough to reject irrelevant output?&lt;/li&gt;
&lt;li&gt;Is the evidence source retained and clearly bounded?&lt;/li&gt;
&lt;li&gt;Is sensitive data excluded or handled in an approved environment?&lt;/li&gt;
&lt;li&gt;Are dangerous shortcuts explicitly forbidden?&lt;/li&gt;
&lt;li&gt;Is one person or role responsible for review?&lt;/li&gt;
&lt;li&gt;Are the success checks observable?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If one answer is missing, the best next action may be to improve the brief instead of running the workflow.&lt;/p&gt;

&lt;p&gt;I built a free &lt;a href="https://growaiskills.com/tools/ai-workflow-brief-builder/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=workflow_brief_article" rel="noopener noreferrer"&gt;AI Workflow Brief Builder&lt;/a&gt; to make this preflight concrete. It runs in the browser, has no model connection, and exports the resulting brief as Markdown or JSON. You can also implement the same structure in a form, issue template, or internal runbook.&lt;/p&gt;

&lt;p&gt;The important part is not the tool. It is the discipline of treating AI work as a bounded process with evidence, permissions, review ownership, and checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical payoff
&lt;/h2&gt;

&lt;p&gt;A workflow brief will not eliminate model errors. It does something more operationally useful: it changes errors from vague surprises into detectable contract violations.&lt;/p&gt;

&lt;p&gt;When a draft makes an unsupported claim, you can point to the evidence boundary. When an agent tries to take an external action, you can point to the forbidden-action list. When a polished output is mistaken for an approved one, you can point to the review owner and approval gate.&lt;/p&gt;

&lt;p&gt;Better prompting helps an AI produce better text. Better workflow boundaries help a team decide whether that text can be trusted and used.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I built the linked AI Workflow Brief Builder. It is free to use and does not require an account.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I stopped writing content and started building tools. Here's the logic</title>
      <dc:creator>Marvin Tang</dc:creator>
      <pubDate>Sun, 21 Jun 2026 07:56:21 +0000</pubDate>
      <link>https://dev.to/imagebear/i-stopped-writing-content-and-started-building-tools-heres-the-logic-46n4</link>
      <guid>https://dev.to/imagebear/i-stopped-writing-content-and-started-building-tools-heres-the-logic-46n4</guid>
      <description>&lt;p&gt;A few months ago I stopped writing new content for most of my sites. Not because the writing was bad, but because I watched the traffic curve long enough to realize I was building for a search engine that had quietly changed the deal.&lt;/p&gt;

&lt;p&gt;If you've searched for anything practical lately, you've seen it. You type a question, and the answer is already there — the AI Overview sits at the top of the page, pulls a response out of three or four sources, and you never click through. For a lot of queries, the click &lt;em&gt;was&lt;/em&gt; the business model. Now it's gone.&lt;/p&gt;

&lt;p&gt;The interesting part isn't that this is happening. It's &lt;em&gt;which&lt;/em&gt; pages it happens to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The asymmetry
&lt;/h2&gt;

&lt;p&gt;An AI Overview is very good at collapsing an answer into a paragraph. "How many tablespoons in a cup." "Difference between &lt;code&gt;let&lt;/code&gt; and &lt;code&gt;const&lt;/code&gt;." "Best time to visit Kyoto." These are knowledge queries — the value is in &lt;em&gt;knowing&lt;/em&gt; something, and a model that has read the whole web can hand you the knowing without the visit. If your page's job was to be the thing that knows the thing, you're now competing against a summary of yourself, shown above you, for free.&lt;/p&gt;

&lt;p&gt;But there's a category the summary can't touch: queries where the value is in the &lt;em&gt;doing&lt;/em&gt;, not the knowing.&lt;/p&gt;

&lt;p&gt;"Format this 4KB of minified JSON." "Count the words in this draft." "Convert these 200 lines to snake_case." You can't satisfy any of those with a paragraph at the top of a results page. The user has data, and they need an interface to do something &lt;em&gt;to&lt;/em&gt; it. The answer isn't information — it's a transformation, and the thing they want doesn't exist until they paste their input in. There's nothing for an Overview to summarize ahead of time.&lt;/p&gt;

&lt;p&gt;That's a real moat, and a weird one, because it has nothing to do with how good your content is. It's about whether your value survives being described.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm doing about it
&lt;/h2&gt;

&lt;p&gt;So I've been running a small experiment around this idea — a hub of single-purpose browser tools at &lt;a href="https://kitset.app" rel="noopener noreferrer"&gt;kitset.app&lt;/a&gt;, each one doing exactly one boring thing: a word counter, a JSON formatter, a case converter. No accounts, no uploads, everything client-side. The thesis I'm testing is plain: tools that depend on the user's own input are roughly immune to the "answer shown above the link" problem in a way that even very good articles are not.&lt;/p&gt;

&lt;p&gt;Early read — it's holding up better than the content sites. But it's too soon to call a win, and there's an obvious counterargument I don't have a clean answer to: a single-purpose tool is also trivially easy for anyone (including the AI) to generate, so any &lt;em&gt;individual&lt;/em&gt; tool has roughly zero defensibility. The moat is at the category level, not the tool level. I'm still chewing on that one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from it
&lt;/h2&gt;

&lt;p&gt;A few things, if you build for the open web:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Will this still earn a click in two years" is now a design question, not just a marketing one.&lt;/strong&gt; If a page's entire value can be lifted into a snippet, assume it eventually will be.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interaction is the cheapest available hedge.&lt;/strong&gt; Anything that takes the user's input and returns a transformation has a property pure content doesn't: it can't be answered in advance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This isn't a reason to stop writing.&lt;/strong&gt; It's a reason to be honest about which pages are &lt;em&gt;content&lt;/em&gt; and which are &lt;em&gt;tools&lt;/em&gt;, and to stop expecting the first kind to behave like the second.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is a grand strategy. It's mostly me reacting to a graph going down and trying to build the thing the graph can't eat. But the shift underneath it feels real, and I suspect more of us will be making this trade over the next couple of years.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>webdev</category>
      <category>tooling</category>
    </item>
    <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>
  </channel>
</rss>
