<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Marvin‘s Dev Blog]]></title><description><![CDATA[Thoughts on indie game development, browser game SEO, and building a solo web publishing portfolio from scratch. Written by the creator of PhyFun, SortFun, 2 Player Fun, and RandTap.]]></description><link>https://imagebear.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Marvin‘s Dev Blog</title><link>https://imagebear.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 14 Sep 2026 21:25:35 GMT</lastBuildDate><atom:link href="https://imagebear.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[A Deterministic Gate for Risky Changes in AI-Generated Text]]></title><description><![CDATA[This article was written with AI assistance. I reviewed the code, examples, and claims before publication. I build Grow AI Skills, the project linked near the end.
AI writing systems are good at produ]]></description><link>https://imagebear.hashnode.dev/deterministic-gate-risky-ai-text-changes</link><guid isPermaLink="true">https://imagebear.hashnode.dev/deterministic-gate-risky-ai-text-changes</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[Testing]]></category><category><![CDATA[Quality Assurance]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Marvin Tang]]></dc:creator><pubDate>Fri, 11 Sep 2026 04:51:33 GMT</pubDate><content:encoded><![CDATA[<p>This article was written with AI assistance. I reviewed the code, examples, and claims before publication. I build Grow AI Skills, the project linked near the end.</p>
<p>AI writing systems are good at producing fluent revisions. Fluency, however, does not tell us whether a revision preserved the facts and release constraints that matter.</p>
<p>A draft can look cleaner while quietly changing <code>USD 24,000</code> to <code>USD 42,000</code>, moving a date by one day, dropping the word <code>not</code>, or altering a protected project name. These changes are small at the token level and potentially large at the decision level.</p>
<p>This tutorial builds a deterministic review gate for those changes. The gate does not decide which version is true. It turns a short list of high-risk differences into a ledger that a human can trace back to an approved source.</p>
<h2>Start with a bounded contract</h2>
<p>Assume this is the source of record:</p>
<pre><code class="language-text">Project Cedar must not ship before September 18, 2026.
The budget cap is USD 24,000.
Mina Okafor must approve the release.
</code></pre>
<p>The AI draft says:</p>
<pre><code class="language-text">Project Cedar may ship before September 19, 2026.
The budget cap is USD 42,000.
Mina Okafor may review the release.
</code></pre>
<p>A useful gate can make five narrow checks:</p>
<ol>
<li>Did a protected name appear or disappear?</li>
<li>Did a money token change?</li>
<li>Did a date token change?</li>
<li>Did a measured unit change?</li>
<li>Did a negation appear or disappear?</li>
</ol>
<p>That is intentionally smaller than “fact-check the draft.” It is a contract we can test.</p>
<h2>Extract tokens by category</h2>
<p>The first step is a small set of explicit patterns:</p>
<pre><code class="language-js">const patterns = {
  Money: /(?:[$€£]\s?\d[\d,]*(?:\.\d+)?|\b(?:USD|EUR|GBP)\s?\d[\d,]*(?:\.\d+)?\b|\b\d[\d,]*(?:\.\d+)?\s?(?:USD|EUR|GBP)\b)/gi,
  Date: /(?:\b\d{4}-\d{2}-\d{2}\b|\b(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+\d{1,2}(?:,)?\s+\d{4}\b)/gi,
  Unit: /\b\d+(?:\.\d+)?\s?(?:%|kg|g|km|m|cm|mm|hours?|days?|minutes?|seconds?)\b/gi,
  Negation: /\b(?:not|no|never|without|cannot|can't|won't|do not|does not|did not|isn't|aren't|must not)\b/gi,
};
</code></pre>
<p>These patterns are not universal parsers. They are review rules for a known content workflow. Add currencies, date formats, or units only when the source material requires them.</p>
<p>Normalize matches before comparison so casing and repeated whitespace do not create noise:</p>
<pre><code class="language-js">const normalize = (value) =&gt;
  value.toLowerCase().replace(/\s+/g, " ").trim();

function countMatches(text, pattern) {
  const counts = new Map();

  for (const match of text.match(pattern) || []) {
    const key = normalize(match);
    const entry = counts.get(key) || { label: match, count: 0 };
    entry.count += 1;
    counts.set(key, entry);
  }

  return counts;
}
</code></pre>
<p>Counting matters. A draft that retains one instance of a date but removes the second instance should not pass just because the token still exists somewhere.</p>
<h2>Build one row for each changed token</h2>
<p>Compare the counted maps and return only mismatches:</p>
<pre><code class="language-js">function compareCategory(source, draft, category, pattern) {
  const before = countMatches(source, pattern);
  const after = countMatches(draft, pattern);
  const keys = new Set([...before.keys(), ...after.keys()]);
  const rows = [];

  for (const key of keys) {
    const a = before.get(key);
    const b = after.get(key);

    if ((a?.count || 0) === (b?.count || 0)) continue;

    rows.push({
      risk:
        category === "Negation" ||
        category === "Money" ||
        category === "Date"
          ? "High"
          : "Review",
      category,
      source: a ? `${a.label} × ${a.count}` : "Not in source",
      draft: b ? `${b.label} × ${b.count}` : "Missing from draft",
      action:
        a &amp;&amp; !b
          ? "Confirm whether removal is allowed."
          : "Trace the added or changed value to an approved source.",
    });
  }

  return rows;
}
</code></pre>
<p>For the budget example, the ledger will contain one row for the removed <code>USD 24,000</code> token and another for the added <code>USD 42,000</code> token. Keeping both rows is useful because each asks a different review question.</p>
<h2>Treat protected terms separately</h2>
<p>Names, product labels, policy titles, and identifiers often do not fit a generic pattern. Pass them as an explicit list:</p>
<pre><code class="language-js">function compareProtected(source, draft, terms) {
  return terms.flatMap((term) =&gt; {
    const before = normalize(source).includes(normalize(term));
    const after = normalize(draft).includes(normalize(term));

    if (before === after) return [];

    return [{
      risk: "High",
      category: "Protected term",
      source: before ? term : "Not in source",
      draft: after ? term : "Missing from draft",
      action: "Verify the exact spelling and approved replacement.",
    }];
  });
}
</code></pre>
<p>The list should come from the job, not from a global pile of keywords. A contract for one release might protect a project codename and approver. A separate contract might protect a medicine name, legal entity, or model number.</p>
<h2>Combine the checks into one deterministic gate</h2>
<pre><code class="language-js">function buildLedger(source, draft, protectedTerms) {
  return [
    ...compareProtected(source, draft, protectedTerms),
    ...Object.entries(patterns).flatMap(([category, pattern]) =&gt;
      compareCategory(source, draft, category, pattern)
    ),
  ];
}
</code></pre>
<p>Call it with the source, the proposed draft, and the terms that the workflow says must remain stable:</p>
<pre><code class="language-js">const rows = buildLedger(sourceText, draftText, [
  "Project Cedar",
  "Mina Okafor",
]);

if (rows.length &gt; 0) {
  console.table(rows);
  // Block release and route the rows to a named reviewer.
}
</code></pre>
<p>The important behavior is not the table formatting. It is that a non-empty ledger prevents an automatic release and produces a concrete review queue.</p>
<h2>Test damaging changes, not just the happy path</h2>
<p>A gate needs negative tests. At minimum, keep fixtures for:</p>
<ul>
<li>an unsupported value added to the draft;</li>
<li>a required unknown or limitation removed from the draft;</li>
<li>a release state changed from “needs approval” to “approved”;</li>
<li>a protected name deleted or altered;</li>
<li>a negation removed from an instruction.</li>
</ul>
<p>For this example, assert that the changed date, changed money, and missing negation all produce rows. Then assert that identical inputs produce an empty ledger.</p>
<pre><code class="language-js">const unchanged = buildLedger(sourceText, sourceText, [
  "Project Cedar",
  "Mina Okafor",
]);

console.assert(unchanged.length === 0);
console.assert(rows.some((row) =&gt; row.category === "Money"));
console.assert(rows.some((row) =&gt; row.category === "Date"));
console.assert(rows.some((row) =&gt; row.category === "Negation"));
</code></pre>
<p>The negative fixtures protect the gate itself. Without them, a refactor can make the interface look healthy while silently weakening a check.</p>
<h2>Put the ledger in a human workflow</h2>
<p>A practical release flow can stay simple:</p>
<pre><code class="language-text">source packet + AI draft
          ↓
deterministic comparison
          ↓
empty ledger? ── no ──&gt; named reviewer resolves each row
     │                         │
    yes                        └──&gt; run the comparison again
     ↓
other required checks
     ↓
human release decision
</code></pre>
<p>Keep three states separate:</p>
<ul>
<li><strong>Detected:</strong> the rule found a token-level difference.</li>
<li><strong>Resolved:</strong> a reviewer traced the difference and recorded the decision.</li>
<li><strong>Approved:</strong> the authorized person accepted the complete release, including checks outside this gate.</li>
</ul>
<p>Detection is not resolution, and a clean ledger is not approval.</p>
<h2>Know what this approach cannot prove</h2>
<p>This checker will miss paraphrases that contain none of the tracked tokens. It does not know whether a source is current, complete, licensed, or correct. Regular expressions can also over-match or under-match unfamiliar formats.</p>
<p>Those are reasons to keep the claim narrow. The gate proves only that its configured checks ran against the supplied texts and returned the recorded result.</p>
<p>If you want to try the workflow without uploading text, I maintain a browser-local <a href="https://growaiskills.com/tools/ai-output-change-checker/?utm_source=hashnode&amp;utm_medium=referral&amp;utm_campaign=output_change_gate">AI Output Change Checker</a>. It covers protected terms, names, dates, amounts, units, and negations, with CSV and JSON export for a review record.</p>
<p>The tool is still a review aid. A zero-result comparison does not prove factual accuracy or authorize release. Human review remains the final boundary.</p>
]]></content:encoded></item><item><title><![CDATA[Cocos Creator 2D Physics on iOS: Notes on Fixed Timestep, ProMotion, and CCD]]></title><description><![CDATA[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]]></description><link>https://imagebear.hashnode.dev/cocos-creator-2d-physics-on-ios-notes-on-fixed-timestep-promotion-and-ccd</link><guid isPermaLink="true">https://imagebear.hashnode.dev/cocos-creator-2d-physics-on-ios-notes-on-fixed-timestep-promotion-and-ccd</guid><dc:creator><![CDATA[Marvin Tang]]></dc:creator><pubDate>Tue, 19 May 2026 04:09:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb8a944b49f4a8e91facc0/ac1510ed-eaa0-42a9-9236-47392aa4411d.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>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.</p>
<p>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.</p>
<p>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.</p>
<h2>1. ProMotion silently changes your simulation</h2>
<p>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 <code>CADisplayLink</code> follow that refresh rate, which means <code>requestAnimationFrame</code> — and the Cocos Creator game loop bound to it — can fire up to 120 times per second on these devices.</p>
<p>If your physics integration uses the per-frame delta time directly — <code>velocity += accel * dt; position += velocity * dt</code> — 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.</p>
<p>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):</p>
<pre><code class="language-typescript">import { PhysicsSystem2D } from 'cc';

PhysicsSystem2D.instance.fixedTimeStep = 1 / 60;
</code></pre>
<p>Rendering still happens at whatever rate the device offers. Physics ticks at 60Hz regardless. Behavior becomes consistent across devices.</p>
<h2>2. Don't integrate with variable dt</h2>
<p>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:</p>
<pre><code class="language-typescript">const FIXED_DT = 1 / 60;
let accumulator = 0;

update(deltaTime: number) {
  accumulator += deltaTime;
  while (accumulator &gt;= FIXED_DT) {
    this.stepPhysics(FIXED_DT);
    accumulator -= FIXED_DT;
  }
  // Optional: interpolate render position using accumulator / FIXED_DT
}
</code></pre>
<p>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 <code>deltaTime</code>, you've reintroduced the inconsistency you just fixed.</p>
<h2>3. CCD is not on by default, and you usually want it</h2>
<p>A projectile 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 projectile is small and your wall is at an angle — there's a real chance the projectile is <em>in front of</em> the wall on frame N and <em>behind</em> it on frame N+1, with no collision detected in between.</p>
<p>Discrete collision detection misses these. The fix is continuous collision detection, which in box2d terms means setting the bullet flag on the rigid body:</p>
<pre><code class="language-typescript">const rb = this.getComponent(RigidBody2D);
rb.bullet = true;
</code></pre>
<p>Two things to know:</p>
<ul>
<li><p>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.</p>
</li>
<li><p>It only protects against passing through static and kinematic bodies. Two CCD-enabled dynamic bodies can still miss each other under box2d's defaults.</p>
</li>
</ul>
<p>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.</p>
<h2>4. Touch input lands later than you think</h2>
<p>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.</p>
<p>That's two to three frames of latency between the finger landing and the projectile reacting. At 60Hz that's 33-50ms — noticeable in a precision physics game where the player is reading visual feedback to aim.</p>
<p>You can't eliminate the WebView → JS hop, but you can avoid adding more delay:</p>
<ul>
<li><p>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.</p>
</li>
<li><p>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.</p>
</li>
</ul>
<h2>5. Background/foreground destroys your physics state</h2>
<p>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 <code>update()</code> call receives a delta time that reflects that entire pause.</p>
<p>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 projectile teleported across the level — through walls, past triggers, anywhere.</p>
<p>Two complementary fixes:</p>
<pre><code class="language-typescript">// Clamp delta to prevent runaway accumulation
update(deltaTime: number) {
  const safeDt = Math.min(deltaTime, 0.1);
  // ... use safeDt for physics
}
</code></pre>
<pre><code class="language-typescript">// Pause physics on visibility change
document.addEventListener('visibilitychange', () =&gt; {
  PhysicsSystem2D.instance.enable = !document.hidden;
});
</code></pre>
<p>The clamp protects you from runaway dt. The <code>visibilitychange</code> handler is cleaner — physics genuinely shouldn't tick when the user can't see the game. Use both.</p>
<h2>6. Determinism is fragile, and you probably don't need it</h2>
<p>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.</p>
<p>This is solvable: fixed-point math, integer-only simulation, or running physics on a server. Each path is heavyweight.</p>
<p>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.</p>
<h2>Pre-ship checklist</h2>
<p>Before submitting a Cocos Creator 2D physics game to App Review, I'd now check:</p>
<ul>
<li><p>Physics fixed timestep is set explicitly, not left to default</p>
</li>
<li><p>Fast-moving rigid bodies have <code>bullet = true</code></p>
</li>
<li><p>Input handlers process within the same tick as the affected physics step</p>
</li>
<li><p><code>deltaTime</code> is clamped, and physics pauses on <code>visibilitychange</code></p>
</li>
<li><p>Tested on at least one ProMotion device (iPhone 13 Pro or later) and one older device</p>
</li>
</ul>
<p>None of this is obscure. It's just easy to miss when desktop preview looks perfect.</p>
<hr />
<p>The game these notes come from is Juicy Ricochet — a Cocos Creator 3.x project I shipped to iOS. 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. Playable in the browser at <a href="https://phyfun.com/game/juicy-ricochet-26888">phyfun.com</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Fixing WPTouch-Generated Duplicate URLs in Google Search Console: A Layered Approach]]></title><description><![CDATA[I opened Google Search Console one morning to a coverage report I didn't recognize. The "Excluded" tab had ballooned overnight with hundreds of new entries, all classified as "Duplicate, Google chose ]]></description><link>https://imagebear.hashnode.dev/fixing-wptouch-generated-duplicate-urls-in-google-search-console-a-layered-approach</link><guid isPermaLink="true">https://imagebear.hashnode.dev/fixing-wptouch-generated-duplicate-urls-in-google-search-console-a-layered-approach</guid><dc:creator><![CDATA[Marvin Tang]]></dc:creator><pubDate>Fri, 08 May 2026 09:45:31 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb8a944b49f4a8e91facc0/ea64183a-834b-427f-9bd8-20bb68c34943.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I opened Google Search Console one morning to a coverage report I didn't recognize. The "Excluded" tab had ballooned overnight with hundreds of new entries, all classified as <strong>"Duplicate, Google chose different canonical than user."</strong> The site was a WordPress install (<a href="https://9puz.com"><code>9puz.com</code></a>) — a few thousand pages, mostly content articles, nothing exotic about its setup. — a few thousand pages, mostly content articles, nothing exotic about its setup.</p>
<p>By the time I'd worked through what was happening, I'd learned more about WordPress URL parameter handling than I wanted to know. This post is the technical breakdown, in case you've hit the same warning.</p>
<h2>The symptom</h2>
<p>Search Console's exclusion report showed roughly a thousand URLs that all pointed back to the same canonicals:</p>
<pre><code class="language-plaintext">https://example.com/some-article/
</code></pre>
<p>But the affected URLs were variants like:</p>
<pre><code class="language-plaintext">https://example.com/some-article/?wptouch_switch=desktop&amp;redirect=...
https://example.com/some-article/?wptouch_switch=mobile&amp;redirect=...
</code></pre>
<p>Each canonical URL had two or three duplicate variants Google had crawled and decided to exclude — but without removing them from its index queue. They sat in "Excluded" purgatory, eating crawl budget, and kept reappearing because Google would re-crawl them periodically.</p>
<h2>Diagnosis</h2>
<p>The query parameter pattern <code>?wptouch_switch=</code> was the giveaway. WPTouch is a WordPress mobile theme plugin that lets a single site serve different layouts to mobile and desktop visitors. It includes a "switch view" link at the bottom of every page so a mobile user can force the desktop layout (or vice versa).</p>
<p>That link works by appending a query parameter to the current URL:</p>
<pre><code class="language-html">&lt;a href="?wptouch_switch=desktop&amp;redirect=/some-article/"&gt;View desktop site&lt;/a&gt;
</code></pre>
<p>When the user clicks, WPTouch sets a cookie indicating their preference, then redirects them. From a user's perspective, this is invisible. From Google's perspective, it's a parade of new URLs being introduced into the crawl queue every time WPTouch renders a footer.</p>
<p>Googlebot finds the link in the HTML, follows it, sees content nearly identical to the parameter-free URL, and files it as a duplicate.</p>
<p>The same canonical URL having multiple duplicates would normally be fine — Google picks one and ignores the others. The problem here was scale. With WPTouch's switch link on every page, every indexed URL got at least one extra duplicate. For a site with several thousand pages, that meant several thousand extra URLs in Search Console reports indefinitely.</p>
<h2>The layered fix</h2>
<p>There's no single setting that fixes this. The right approach is a layered defense — each layer reduces the problem somewhat, and combined they eliminate it.</p>
<h3>Layer 1: robots.txt</h3>
<p>The first line of defense is preventing Googlebot from crawling these URLs in the first place. Add this to <code>robots.txt</code>:</p>
<pre><code class="language-plaintext">User-agent: *
Disallow: /*?wptouch_switch=
Disallow: /*&amp;wptouch_switch=
</code></pre>
<p>The two rules cover both cases — where <code>wptouch_switch</code> is the first parameter (preceded by <code>?</code>) and where it's a subsequent parameter (preceded by <code>&amp;</code>).</p>
<p>This stops new crawls of these URLs but doesn't remove what's already indexed.</p>
<h3>Layer 2: canonical tags</h3>
<p>WordPress SEO plugins (Yoast, Rank Math, AIOSEO) usually add <code>rel="canonical"</code> tags to pages, and they usually point to the clean URL by default — but you should verify. Open one of the flagged URLs in your browser, view the source, and confirm:</p>
<pre><code class="language-html">&lt;link rel="canonical" href="https://example.com/some-article/" /&gt;
</code></pre>
<p>Note the canonical URL has no <code>?wptouch_switch=</code> parameter. If your SEO plugin is generating canonicals that <em>include</em> the parameter, you have a deeper problem and need to fix the plugin configuration or override.</p>
<p>For the case where you can't rely on the plugin's default, a small filter in your theme's <code>functions.php</code> works:</p>
<pre><code class="language-php">add_filter('wpseo_canonical', function($canonical) {
    return strtok($canonical, '?');
});
</code></pre>
<p>This strips any query string from the canonical URL Yoast generates. Swap the filter name for other SEO plugins (<code>rank_math/frontend/canonical</code>, <code>aioseo_canonical_url</code>, etc.).</p>
<h3>Layer 3: 301 redirect for the parameter</h3>
<p>For maximum cleanup, redirect <code>?wptouch_switch=</code> requests to the parameter-free version. This is more aggressive than the previous layers because it changes user behavior too — but in this case it's safe, because by the time the redirect runs, real users have already had their cookie set on the click.</p>
<p>Add to <code>.htaccess</code>:</p>
<pre><code class="language-apache">RewriteEngine On
RewriteCond %{QUERY_STRING} (^|&amp;)wptouch_switch=
RewriteRule ^(.*)\( /\)1? [R=301,L]
</code></pre>
<p>The trailing <code>?</code> in the rewrite target strips the existing query string. The <code>R=301</code> makes it a permanent redirect, which is what tells Google to deindex the parameter version over time.</p>
<h3>Layer 4: Search Console URL Removal (cleanup acceleration)</h3>
<p>For URLs already flagged in Search Console, the layered fix above will eventually clear them — but it can take weeks for Google to re-crawl every duplicate and confirm the change. To accelerate cleanup:</p>
<ol>
<li><p>Go to Search Console → "Removals" tool.</p>
</li>
<li><p>Submit a "Temporarily remove URL" request with a path prefix matching <code>wptouch_switch</code>. Use the "Remove all URLs with this prefix" option.</p>
</li>
<li><p>Wait 24 hours for Google to honor the request.</p>
</li>
</ol>
<p>This doesn't remove URLs from Google's database — it just hides them from search results for around six months, which is enough time for the canonical and robots fixes to take full effect.</p>
<h2>Verification</h2>
<p>After deploying all four layers, the cleanup timeline looked roughly like this:</p>
<ul>
<li><p><strong>Day 1</strong>: All four layers in place, redeployed.</p>
</li>
<li><p><strong>Day 3</strong>: <code>robots.txt</code> fetched by Googlebot. New crawls of <code>?wptouch_switch=</code> URLs stopped showing up in server logs.</p>
</li>
<li><p><strong>Day 7</strong>: Search Console "Excluded" report stable; no new duplicates being added.</p>
</li>
<li><p><strong>Day 21</strong>: Excluded count starts to drop as Google re-crawls existing duplicates and confirms the 301.</p>
</li>
<li><p><strong>Day 60</strong>: Excluded count down to a small residual; the warning effectively cleared.</p>
</li>
</ul>
<p>The total work was maybe two hours, including the time to write and test the <code>.htaccess</code> rule. The payoff is more than aesthetic — Google's crawl budget is now spent on real content rather than parameter variants, and the Coverage report is finally a useful diagnostic surface again.</p>
<h2>Lessons</h2>
<p>A few things worth internalizing for future WordPress URL hygiene:</p>
<p><strong>Audit query parameter sources.</strong> WPTouch is the most common offender I've seen, but other plugins introduce similar patterns — caching plugins, geolocation plugins, social sharing widgets, A/B testing tools. Anything that adds <code>?something=value</code> links into your rendered HTML can do this.</p>
<p><strong>Canonicals alone aren't enough.</strong> Most write-ups treat canonical tags as the complete solution. They aren't. Without robots.txt and the 301 layer, Google will still crawl, still index temporarily, and still report duplicates.</p>
<p><strong>Path-prefix removal is underused.</strong> The Search Console URL Removal tool's prefix option is one of the few things that gives you fast-acting feedback during cleanup. Most operators don't know it exists.</p>
<p><strong>Don't troubleshoot from a single browser.</strong> When you can't replicate the duplicate URL issue in your own browser (because you don't see the WPTouch link, or because your view is "desktop already"), you'll dismiss the warning. Always check Googlebot's view — Search Console's URL Inspection tool is the definitive source.</p>
<p>The next time Search Console surprises me with hundreds of new exclusions, the first thing I'm going to do is sort them by URL pattern. Almost every "sudden" duplicate problem turns out to have a single common parameter at the root.</p>
]]></content:encoded></item><item><title><![CDATA[Three Months Migrating from LayaAir to Cocos Creator: API Differences, Build Pipeline, and Real-World Gotchas]]></title><description><![CDATA[I spent the last three months porting a browser game project from LayaAir to Cocos Creator. This post covers the actual API differences, build pipeline changes, and a list of the specific issues I ran]]></description><link>https://imagebear.hashnode.dev/three-months-migrating-from-layaair-to-cocos-creator-api-differences-build-pipeline-and-real-world-gotchas</link><guid isPermaLink="true">https://imagebear.hashnode.dev/three-months-migrating-from-layaair-to-cocos-creator-api-differences-build-pipeline-and-real-world-gotchas</guid><dc:creator><![CDATA[Marvin Tang]]></dc:creator><pubDate>Fri, 24 Apr 2026 10:21:05 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb8a944b49f4a8e91facc0/89b600f8-a8a1-4bef-b425-20be3a0812d9.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I spent the last three months porting a browser game project from LayaAir to Cocos Creator. This post covers the actual API differences, build pipeline changes, and a list of the specific issues I ran into — the kind of details you need when making this migration decision yourself.</p>
<h3>Why I Migrated</h3>
<p>The trigger was multi-platform export. LayaAir can technically output to multiple platforms, but the tooling and documentation around non-WebGL targets (WeChat mini games in particular) had become thin. Cocos Creator has invested heavily in mini game export and now powers the majority of top-ranked WeChat mini games, which tells you where serious mobile developers have converged.</p>
<p>If you only need WebGL and nothing else, LayaAir remains a reasonable choice. It's lighter, faster to get started, and sufficient for most small-to-medium browser games. The decision to migrate only made sense because my project roadmap included iOS, Android, and WeChat mini game targets.</p>
<h3>Scene System Differences</h3>
<p>This was the biggest conceptual shift.</p>
<p>LayaAir treats scenes as flexible containers. You instantiate nodes, add them to the stage, and manage the tree manually. It feels natural if you come from a DOM-manipulation background.</p>
<p>Cocos Creator uses a more opinionated scene graph built around the prefab system. The engine expects most reusable visual elements to be prefabs, instantiated via the <code>instantiate()</code> API, and managed through a component-based lifecycle similar to Unity.</p>
<p>My first Cocos project had scenes that looked like LayaAir scenes translated literally — lots of manual node instantiation, minimal prefabs. Code size roughly halved once I refactored to proper prefab usage.</p>
<p>The core pattern:</p>
<p>typescript</p>
<pre><code class="language-typescript">// LayaAir style (works in Cocos but misses the point)
const node = new Node();
node.addComponent(Sprite);
node.parent = this.node;

// Cocos Creator idiomatic
const instance = instantiate(this.prefab);
instance.parent = this.node;
</code></pre>
<h3>Asset Loading and Bundles</h3>
<p>LayaAir's asset loading is flexible but leaves most optimization to the developer. You decide what to preload, what to lazy-load, what to release.</p>
<p>Cocos Creator introduces an AssetBundle system that handles most of this declaratively. Bundles can be packaged with the main build, loaded on demand, or fetched from a CDN. For WeChat mini games with 4 MB initial package limits, bundles are not optional — they're the only way to stay under the size cap.</p>
<p>The common mistake when starting with bundles is over-splitting. I initially created a bundle per scene plus several feature bundles, ending up with 20+ bundles that each required a network round trip to load. The right granularity is coarser: group by major gameplay section, not by individual scene.</p>
<p>Loading a bundle in code:</p>
<p>typescript</p>
<pre><code class="language-typescript">assetManager.loadBundle('gameplay', (err, bundle) =&gt; {
  bundle.load('prefabs/Enemy', Prefab, (err, prefab) =&gt; {
    const enemy = instantiate(prefab);
    this.node.addChild(enemy);
  });
});
</code></pre>
<h3>TypeScript Experience</h3>
<p>Both engines support TypeScript. The experience is noticeably better in Cocos Creator.</p>
<p>Decorators for components and serialized properties are mature and well-documented:</p>
<p>typescript</p>
<pre><code class="language-typescript">const { ccclass, property } = _decorator;

@ccclass('Enemy')
export class Enemy extends Component {
  @property(Number)
  health: number = 100;

  @property(Node)
  target: Node = null;

  start() {
    this.schedule(this.updateTarget, 0.5);
  }

  updateTarget() {
    // logic
  }
}
</code></pre>
<p>Autocomplete works reliably across the engine API, decorator-based property editors generate correctly in the inspector, and type inference for component references is accurate.</p>
<p>LayaAir's TypeScript support exists but feels bolted on. The build process requires more manual configuration, and editor integration is less polished.</p>
<h3>The WeChat Mini Game Pipeline</h3>
<p>This is where Cocos earns its reputation. The WeChat build target works with minimal configuration for most projects:</p>
<ol>
<li><p>Set build target to "WeChat Game" in the build panel</p>
</li>
<li><p>Configure the AppID and basic manifest</p>
</li>
<li><p>Build — output is a ready-to-upload <code>build/wechatgame</code> folder</p>
</li>
</ol>
<p>The constraints you need to understand before the first build:</p>
<ul>
<li><p><strong>Initial package size</strong>: 4 MB hard limit</p>
</li>
<li><p><strong>Total package size</strong>: 20 MB with subpackages</p>
</li>
<li><p><strong>Memory limit</strong>: roughly 500 MB on most devices, less on older ones</p>
</li>
<li><p><strong>File system</strong>: read-only for bundled files, writable scratch area for user data</p>
</li>
<li><p><strong>Networking</strong>: HTTPS-only, requires pre-declared domain allowlist</p>
</li>
</ul>
<p>Texture compression matters enormously here. I reduced initial package size by 40% just by switching from PNG to ETC1/ETC2 compression for in-game sprites. UI assets stay as PNG for quality reasons; gameplay assets use compressed formats.</p>
<h3>Apple Silicon Editor Issues</h3>
<p>Developing on an M1/M2 Mac is mostly smooth. Cocos Creator runs natively on Apple Silicon, builds are fast, and the profiler works correctly.</p>
<p>One consistent issue: the editor occasionally loses keyboard focus on its main window. You click a panel, type something, nothing happens — the window technically has focus but isn't receiving input. Clicking the title bar restores it.</p>
<p>This appears to be a macOS window management interaction, not a Cocos-specific bug, but it's frequent enough to note. Nothing has been lost from it, but it's a papercut that adds up over a long day.</p>
<h3>Animation System</h3>
<p>LayaAir's animation system is minimal — tween properties over time with some curve support.</p>
<p>Cocos Creator's Animation Editor is closer to a dedicated animation tool. Multi-track timelines, frame events, and clip management are all built in.</p>
<p>The tradeoff: simple tweens that are one line in LayaAir sometimes require opening the Animation Editor in Cocos. For a game with significant UI or character animation, the Cocos editor is a large productivity win. For a game with only a few tween effects, LayaAir is faster.</p>
<h3>Build Times</h3>
<p>Cocos Creator builds are slower than LayaAir builds for equivalent project sizes. An initial build of a medium project takes a few minutes. Incremental builds are faster but still not instant.</p>
<p>Practical workaround: rely heavily on the in-editor preview for iteration. Full builds are for validating export-target-specific behavior — mini game memory usage, iOS retina rendering, etc. — not for day-to-day development.</p>
<h3>Documentation Reality</h3>
<p>Cocos Creator's documentation is extensive but fragmented. Official docs, forum posts, and community tutorials sometimes disagree, and older tutorials may reference APIs that changed between major versions.</p>
<p>The Chinese-language community is significantly larger than the English community. Some of the best debugging resources are in Chinese forum posts and Chinese YouTube videos. If you're an English-only developer, expect to use translation tools regularly.</p>
<p>LayaAir has similar dynamics but smaller overall volume. For English documentation availability, neither engine is ideal.</p>
<h3>Decision Framework</h3>
<p>Migrate to Cocos Creator if:</p>
<ul>
<li><p>You need WeChat, Douyin, or other Asian mini-game platforms</p>
</li>
<li><p>You need first-class iOS/Android export without relying on third-party wrappers</p>
</li>
<li><p>You're building something with significant animation or UI complexity</p>
</li>
<li><p>You want component-based architecture similar to Unity</p>
</li>
</ul>
<p>Stick with LayaAir if:</p>
<ul>
<li><p>Your target is WebGL only</p>
</li>
<li><p>Your project is small and iteration speed matters more than platform flexibility</p>
</li>
<li><p>You're more productive in a lightweight, flexible scene model</p>
</li>
</ul>
<h3>Takeaway</h3>
<p>Three weeks of reduced productivity during the migration transition, then the new workflow became natural. The project is now running in production on <a href="https://phyfun.com">phyfun.com</a>, a small browser games platform I maintain, and the Cocos-based version is easier to extend than the LayaAir version ever was.</p>
<p>If you're considering this migration, start with a small throwaway project. The common early mistakes — over-instantiating nodes, under-using prefabs, mis-configuring bundles — are cheap to make in a learning project and expensive to make in production code.</p>
]]></content:encoded></item><item><title><![CDATA[From SiteGround CDN to Cloudflare: Setup Guide and Gotchas]]></title><description><![CDATA[From SiteGround CDN to Cloudflare: Setup Guide and Gotchas
I run a portfolio of small content and game sites, most of them hosted on SiteGround. For a long time I used SiteGround's built-in CDN — it s]]></description><link>https://imagebear.hashnode.dev/from-siteground-cdn-to-cloudflare-setup-guide-and-gotchas</link><guid isPermaLink="true">https://imagebear.hashnode.dev/from-siteground-cdn-to-cloudflare-setup-guide-and-gotchas</guid><dc:creator><![CDATA[Marvin Tang]]></dc:creator><pubDate>Mon, 20 Apr 2026 02:42:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb8a944b49f4a8e91facc0/cca1fe42-39c8-4cc3-8a1a-268bce6b72b0.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>From SiteGround CDN to Cloudflare: Setup Guide and Gotchas</h1>
<p>I run a portfolio of small content and game sites, most of them hosted on SiteGround. For a long time I used SiteGround's built-in CDN — it ships in the control panel, it's one toggle to enable, and for a while it worked fine.</p>
<p>Then it didn't.</p>
<p>Over a few weeks I started seeing intermittent "Not Secure" warnings on my sites. A page would load normally, refresh, and suddenly the browser would warn about an insecure connection. Refresh again and it would be fine. Not every user hit it, not every page, not every time — but often enough that I couldn't ignore it.</p>
<p>After some diagnosis, I decided to stop trying to fix the behavior of a black box and migrate to Cloudflare. I tested the process on one site first (<a href="https://idognames.com">idognames.com</a>) and then rolled it out across the rest of my portfolio.</p>
<p>This post walks through the setup, the configuration decisions that matter, and the real gotchas I ran into — so if you're doing the same migration, you don't have to learn them the hard way.</p>
<h2>Why leave SiteGround's CDN?</h2>
<p>SiteGround's CDN is convenient, but it's also opaque. When it works, it works. When it doesn't, you're mostly at the mercy of support tickets.</p>
<p>My specific trigger was intermittent HTTPS issues. But the broader reasons Cloudflare is worth considering:</p>
<ul>
<li><p><strong>Global edge network</strong> — Cloudflare has 300+ points of presence worldwide; SiteGround's CDN is far smaller</p>
</li>
<li><p><strong>Free tier covers real-world needs</strong> — unlimited bandwidth, free SSL, basic DDoS protection</p>
</li>
<li><p><strong>Granular cache control</strong> — cache rules, page rules, purge-by-URL, all on free plan</p>
</li>
<li><p><strong>One dashboard for multiple sites</strong> — useful if you, like me, run a portfolio</p>
</li>
<li><p><strong>Better ecosystem</strong> — R2, Workers, Pages, Images, Zaraz are all one click away if you need them later</p>
</li>
</ul>
<p>Cloudflare also hides your origin server IP, which is a nice-to-have if you're paranoid about being targeted directly.</p>
<h2>Pre-migration checklist</h2>
<p>Before touching anything, do this:</p>
<p><strong>1. Confirm your SSL certificate is active on SiteGround.</strong> Go to Site Tools → Security → SSL Manager. You should see an active Let's Encrypt (or other) certificate. This is important because after the migration, SiteGround still serves the origin — the certificate stays where it is.</p>
<p><strong>2. Note your server IP.</strong> Site Tools → Site → Site Information. Write down the "Site IP" value. You'll need it to verify DNS records later.</p>
<p><strong>3. Document any custom</strong> <code>.htaccess</code> <strong>rules.</strong> If you have custom redirects, force-HTTPS rules, or security headers, screenshot them. They'll still work after the migration, but it's worth having a reference.</p>
<p><strong>4. Pick a test site.</strong> Don't migrate all your sites at once. Pick one you can tolerate being offline briefly — ideally not your highest-traffic one. I used a mid-traffic niche content site as my test bed.</p>
<h2>Step 1: Add your site to Cloudflare</h2>
<p>Sign up for a free Cloudflare account. Click "Add a site", enter your domain, and pick the <strong>Free plan</strong> — for most small to medium sites this is genuinely enough.</p>
<p>Cloudflare will then scan your domain's DNS records and show you what it found. This is where the first gotcha shows up — see the next section.</p>
<h2>Step 2: DNS records review (the real gotcha section)</h2>
<p>When Cloudflare scans your DNS, it pulls from multiple sources — including historical DNS databases. That means the list you're shown may include records that haven't been live in years.</p>
<p><strong>You need to manually check every record before proceeding.</strong></p>
<p>Things to look for:</p>
<ul>
<li><p><strong>All your main A records should point to your current server IP.</strong> If you see an A record pointing to a different IP, investigate before keeping it. It might be a legitimate separate service, or it might be a ghost from an old setup.</p>
</li>
<li><p><strong>Subdomains you don't use can be deleted.</strong> I had <code>autoconfig</code>, <code>autodiscover</code>, <code>ftp</code>, <code>mail</code>, and <code>ssh</code> subdomains imported automatically. If you don't use these services under your domain, delete the records — they're noise.</p>
</li>
<li><p><strong>MX records for email should stay as "DNS only"</strong> (gray cloud). Cloudflare's proxy doesn't handle SMTP; if you proxy mail records, email breaks.</p>
</li>
<li><p><strong>TXT records for SPF, DKIM, DMARC stay as "DNS only"</strong> too.</p>
</li>
<li><p><strong>Web-serving A and CNAME records should be set to "Proxied"</strong> (orange cloud) — this is what actually routes traffic through Cloudflare's CDN.</p>
</li>
</ul>
<blockquote>
<p><strong>Tip:</strong> Cloudflare's auto-scan is a starting point, not the final answer. Treat it like a pull request from a stranger: review every line before merging.</p>
</blockquote>
<h2>Step 3: Change nameservers</h2>
<p>Cloudflare gives you two nameserver addresses, something like <code>west.ns.cloudflare.com</code> and <code>desi.ns.cloudflare.com</code>.</p>
<p>Go to your domain registrar (wherever you bought the domain — Namecheap, GoDaddy, Porkbun, etc.) and replace the existing nameservers with the two Cloudflare gave you.</p>
<p>Save, and wait.</p>
<p>DNS propagation can take anywhere from 15 minutes to a few hours, occasionally up to 24. You can check progress with:</p>
<pre><code class="language-bash">dig yourdomain.com NS +short
</code></pre>
<p>When you see the two Cloudflare nameservers in the output, propagation is complete for your current DNS resolver. Cloudflare will also email you when it detects that the nameserver change is live and your site is fully active.</p>
<h2>Step 4: SSL/TLS configuration</h2>
<p>This is the part most people get wrong. Open Cloudflare → SSL/TLS → Overview.</p>
<p>You'll see four modes: Off, Flexible, Full, Full (strict). Here's what each actually does:</p>
<ul>
<li><p><strong>Off</strong> — no HTTPS. Don't.</p>
</li>
<li><p><strong>Flexible</strong> — browser to Cloudflare is HTTPS, Cloudflare to your origin is plain HTTP. <strong>This will break your site</strong> if your origin redirects HTTP to HTTPS (which most do), because you'll end up in a redirect loop.</p>
</li>
<li><p><strong>Full</strong> — both legs are HTTPS, but Cloudflare doesn't strictly verify your origin certificate. Tolerant of self-signed or expired certificates.</p>
</li>
<li><p><strong>Full (strict)</strong> — both legs HTTPS, and Cloudflare verifies your origin certificate is valid and not expired.</p>
</li>
</ul>
<p><strong>Set it to Full (strict).</strong> Your SiteGround-managed Let's Encrypt certificate is a valid, publicly-trusted certificate, so strict mode works out of the box. This is the most secure option, and there's no reason to accept less if your origin certificate is good.</p>
<p>Then go to SSL/TLS → Edge Certificates and configure:</p>
<ul>
<li><p><strong>Minimum TLS Version: TLS 1.2</strong> — The default is TLS 1.0, which has been deprecated for years. Raise it to 1.2.</p>
</li>
<li><p><strong>TLS 1.3: On</strong> — Modern, faster, more secure.</p>
</li>
<li><p><strong>Always Use HTTPS: On</strong> — Any HTTP request gets 301-redirected to HTTPS at the edge, before it even reaches your origin.</p>
</li>
<li><p><strong>Automatic HTTPS Rewrites: On</strong> — If any of your pages have <code>http://</code> links in them (old content, for example), this rewrites them on the fly to <code>https://</code>, avoiding mixed content warnings.</p>
</li>
</ul>
<p>These five settings are the minimum I'd consider "correct" for a modern site in 2026.</p>
<h2>Step 5: Caching</h2>
<p>Cloudflare's free tier caches static assets by default — images, CSS, JS, fonts. HTML pages are not cached by default.</p>
<p>For most content-heavy sites, you want HTML cached too. The simplest way:</p>
<ol>
<li><p>Go to Caching → Configuration</p>
</li>
<li><p>Make sure "Caching Level" is set to <strong>Standard</strong></p>
</li>
<li><p>Create a Cache Rule under Rules → Cache Rules: match <code>yourdomain.com/*</code>, action: "Cache eligible for cache", with an edge TTL of 2 hours (or whatever fits your update cadence)</p>
</li>
</ol>
<p>If you update content frequently, you can shorten the TTL, or use Cloudflare's "Purge Everything" button after publishing.</p>
<h2>Things I ran into</h2>
<p>A few smaller gotchas worth mentioning:</p>
<p><strong>SiteGround's HTTPS Enforce and Cloudflare's "Always Use HTTPS" are not the same layer.</strong> SiteGround enforces HTTPS at the origin server. Cloudflare enforces it at the edge. Having both on is fine — they don't conflict, they stack. Cloudflare gets the request first and redirects before the origin is touched.</p>
<p><strong>SSL certificates are still managed by SiteGround.</strong> A common confusion: "if I'm behind Cloudflare, do I still need a certificate on my origin?" Yes. Your origin still needs a valid certificate — that's what Full (strict) mode verifies. Keep your Let's Encrypt certificate renewing normally on SiteGround.</p>
<p><strong>Don't forget to turn off SiteGround's CDN.</strong> After the migration, SiteGround's own CDN is no longer doing useful work. Disable it in Site Tools → Speed → CDN. Leaving it on can create an unnecessary extra hop, though it won't actively break things.</p>
<p><strong>Check caching behavior after migration.</strong> Your cache-hit ratio will start low and grow over time as Cloudflare warms up. Don't judge performance in the first hour — give it a day or two.</p>
<h2>Before and after</h2>
<p>I didn't run formal benchmarks, but subjectively:</p>
<ul>
<li><p>The intermittent "Not Secure" warnings that triggered the migration are gone</p>
</li>
<li><p>Cache control is dramatically more flexible (I can now cache HTML, which SiteGround's CDN didn't do for me)</p>
</li>
<li><p>I can see actual traffic and cache analytics in the Cloudflare dashboard</p>
</li>
<li><p>The origin is now hidden behind Cloudflare's IPs, which is a small but real security improvement</p>
</li>
</ul>
<p>The migration itself took maybe 30 minutes per site once I'd done the first one. The hardest part was getting the first one right and confident enough to replicate the pattern.</p>
<h2>Should you migrate?</h2>
<p>Cloudflare's free tier is a better CDN than most hosts bundle by default — and that's not controversial, it's just the reality of the market. If you're on SiteGround and your CDN is working fine for you, there's no urgency. But if you're hitting intermittent issues, or if you just want more control and visibility, Cloudflare is a solid replacement.</p>
<p>I've now migrated my entire portfolio. No regrets so far. Starting with one test site was the right call — it gave me a repeatable playbook before I touched the sites I actually care about.</p>
<hr />
<p>If you've done this migration and ran into something I didn't cover, I'd love to hear about it in the comments.</p>
<p><strong>Playing around with small games in the browser:</strong> <a href="https://phyfun.com">phyfun.com</a> <strong>My game portfolio:</strong> <a href="https://marvingames.com">marvingames.com</a></p>
]]></content:encoded></item><item><title><![CDATA[Building Juicy Ricochet: What I Learned Making a Physics Puzzle Game from Scratch]]></title><description><![CDATA[Physics puzzle games have a deceptively simple design promise: one mechanic, infinite variation. The player fires a projectile, it bounces off surfaces, something gets destroyed. That's the entire loo]]></description><link>https://imagebear.hashnode.dev/building-juicy-ricochet-what-i-learned-making-a-physics-puzzle-game-from-scratch</link><guid isPermaLink="true">https://imagebear.hashnode.dev/building-juicy-ricochet-what-i-learned-making-a-physics-puzzle-game-from-scratch</guid><dc:creator><![CDATA[Marvin Tang]]></dc:creator><pubDate>Tue, 31 Mar 2026 09:39:32 GMT</pubDate><content:encoded><![CDATA[<p>Physics puzzle games have a deceptively simple design promise: one mechanic, infinite variation. The player fires a projectile, it bounces off surfaces, something gets destroyed. That's the entire loop. What makes it interesting — or frustrating — is everything that happens in between.</p>
<p>Juicy Ricochet started from that premise. Shoot bullets from a cannon. Destroy all the fruit targets. Use as few shots as possible. I shipped version 1.0 in November 2025. By December, version 2.0 was live with a completely reworked feature set. Here's what the development process actually looked like.</p>
<p><strong>The core mechanic and why it works</strong></p>
<p>Ricochet as a puzzle mechanic has a property that most puzzle genres don't: the solution space is continuous, not discrete. In a grid-based puzzle, there are a finite number of moves. In a ricochet game, a one-degree difference in your aim angle produces a completely different outcome. That sensitivity is both what makes the mechanic satisfying and what makes it hard to balance.</p>
<p>The fruit theme came from wanting something visually clear and satisfying to destroy. Apples, oranges, bananas, watermelons, grapes, strawberries — each target type is immediately readable at a glance, which matters when you're trying to plan a trajectory across a crowded level. The "juicy" in the title isn't decoration; it's a design directive. Every successful hit should feel like something.</p>
<p><strong>Designing 168 levels without a team</strong></p>
<p>Solo level design at this scale is a grind. The first 20 levels exist to teach — each one introduces a new wall angle, a new obstacle configuration, a new way to chain shots. The middle section varies the combinations. The final stretch combines everything and removes the safety nets.</p>
<p>The hardest design problem was difficulty calibration. A level that takes me three attempts after designing it will often take a new player twenty. I ended up playing every level cold — after sleeping, without context — to get closer to a real player's perspective. It's not a perfect method, but it's better than testing immediately after building.</p>
<p><strong>What changed in version 2.0</strong></p>
<p>Version 1.0 shipped with the core loop intact but missing layers that players expected. The feedback was clear: people wanted to know where their bullet was going before they fired.</p>
<p>Version 2.0 added the Route Preview system — a trajectory prediction line that shows the full bounce path before you commit to a shot. This was the single most impactful feature addition. It shifted the game from reaction-based to genuinely strategic. Players stopped guessing and started planning.</p>
<p>The other major addition was the Bonus Shots system: extra shots that carry over between levels. This addressed the frustration of being stuck on a single level indefinitely. Combined with the 3-star rating system, it gave players two parallel goals — complete the level, then go back and optimize.</p>
<p>The tutorial system in Level 1 was the last piece. A game about trajectory physics needs its first level to do the teaching, not a separate tutorial screen that players skip.</p>
<p><strong>Browser first, then mobile</strong></p>
<p>The browser version of Juicy Ricochet is playable now at <a href="https://phyfun.com/game/juicy-ricochet-26888">phyfun.com/game/juicy-ricochet-26888</a>. The iOS version is live on the App Store for iPhone and iPad. Building in Cocos Creator meant the same codebase targets both — the physics, the level data, the UI logic all transfer. The main adaptation work is touch controls and screen layout, not a ground-up rebuild.</p>
<p><strong>The one thing I'd do differently</strong></p>
<p>Ship version 2.0 as version 1.0. The Route Preview system should have been there from the start. Players who encountered the game before it existed formed impressions that the update couldn't fully reverse. If you're building a physics puzzle game, the trajectory preview isn't a feature — it's the game.</p>
]]></content:encoded></item><item><title><![CDATA[Building Cosmic Summon: How I Made a Gacha Tower Defense Game as a Solo Developer]]></title><description><![CDATA[Tower defense is one of those genres that looks simple from the outside. Place units, stop enemies, survive waves. But the moment you start building one, you realize how many interdependent systems yo]]></description><link>https://imagebear.hashnode.dev/building-cosmic-summon-how-i-made-a-gacha-tower-defense-game-as-a-solo-developer</link><guid isPermaLink="true">https://imagebear.hashnode.dev/building-cosmic-summon-how-i-made-a-gacha-tower-defense-game-as-a-solo-developer</guid><dc:creator><![CDATA[Marvin Tang]]></dc:creator><pubDate>Tue, 31 Mar 2026 09:31:57 GMT</pubDate><content:encoded><![CDATA[<p>Tower defense is one of those genres that looks simple from the outside. Place units, stop enemies, survive waves. But the moment you start building one, you realize how many interdependent systems you're actually dealing with. Cosmic Summon taught me that lesson in full.</p>
<p><strong>Where the idea came from</strong></p>
<p>I wanted to build a tower defense game that felt different from the standard "place turrets on a grid" formula. The mechanic that stuck was merging — instead of upgrading towers in place, you combine two identical units to evolve them into something stronger. It changes the entire decision loop. You're not just thinking about placement, you're managing a board, holding duplicates, and deciding when to push for the next rarity tier.</p>
<p>The gacha summoning layer came naturally from there. If merging is the core upgrade mechanic, summoning is how you acquire the pieces. The two systems reinforce each other: summon heroes, place them strategically, merge duplicates to evolve, adapt your formation as new enemy types appear.</p>
<p><strong>Designing 12 heroes from scratch</strong></p>
<p>The hardest design challenge was making each hero feel meaningfully different without creating an obvious best choice. Cosmic Summon ships with 12 heroes across three roles: damage dealers, crowd controllers, and support units.</p>
<p>The damage dealers were straightforward to design — Wind Piercer with penetrating shots, Blaze Caster with fire splash, Cannon Lord with wide-radius devastation. The interesting design space was in the support heroes. Wall Keeper heals your wall over time, Gold Digger generates passive income, and Melodist buffs team attack speed. Getting the balance right between these roles took more iteration than anything else in the game.</p>
<p><strong>28 enemy types and why variety matters</strong></p>
<p>A tower defense game lives or dies by its enemy design. If every enemy is just a slightly faster or tougher version of the last one, players stop thinking and start grinding. I designed 28 enemy types specifically to disrupt whatever formation the player had settled into.</p>
<p>Stealth lurkers that vanish from sight force you to place heroes with area detection. Splitters that divide on death punish splash-heavy compositions. Reflectors that bounce damage back make you rethink your highest-DPS heroes entirely. Boss encounters every 5 waves are designed to test your entire formation, not just your frontline.</p>
<p><strong>The constellation theme</strong></p>
<p>The 50-wave structure needed a visual identity that didn't feel arbitrary. Real constellations — Aries, Orion, Cassiopeia, the Southern Cross — gave each wave a backdrop that felt intentional without requiring a narrative explanation. Players recognize the names and shapes, which creates a small moment of familiarity in an otherwise chaotic gameplay loop.</p>
<p><strong>Where it is now</strong></p>
<p>The browser version of Cosmic Summon is live now at <a href="https://phyfun.com/game/cosmic-summon-tower-defense-27195">phyfun.com</a>. The iOS version is currently in App Store review, and a submission to CrazyGames is also pending review. Building a game that works across browser, mobile, and game portals from a single Cocos Creator codebase is one of the biggest practical advantages of the HTML5-first approach — one build, multiple distribution channels.</p>
<p>If you're a solo developer thinking about building a tower defense game, my honest advice is to start with the enemy design, not the hero design. The enemies define the problem space. The heroes are just the tools you give players to solve it.</p>
]]></content:encoded></item></channel></rss>