<?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: Liza Bhadana</title>
    <description>The latest articles on DEV Community by Liza Bhadana (@liza_18827c15bdd387c4cd9c).</description>
    <link>https://dev.to/liza_18827c15bdd387c4cd9c</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%2F3949224%2F0c54c5a5-e080-4bad-865b-94cf5e95b6bc.jpg</url>
      <title>DEV Community: Liza Bhadana</title>
      <link>https://dev.to/liza_18827c15bdd387c4cd9c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/liza_18827c15bdd387c4cd9c"/>
    <language>en</language>
    <item>
      <title>Your og:image is probably the wrong shape: we measured 9 sites</title>
      <dc:creator>Liza Bhadana</dc:creator>
      <pubDate>Wed, 12 Aug 2026 09:14:15 +0000</pubDate>
      <link>https://dev.to/liza_18827c15bdd387c4cd9c/your-ogimage-is-probably-the-wrong-shape-we-measured-9-sites-58a6</link>
      <guid>https://dev.to/liza_18827c15bdd387c4cd9c/your-ogimage-is-probably-the-wrong-shape-we-measured-9-sites-58a6</guid>
      <description>&lt;p&gt;Every link we had ever shared rendered as a cropped slice of our own logo. The image was &lt;strong&gt;1695 × 387&lt;/strong&gt; — a 4.38:1 strip — and the card type our pages declare renders at about &lt;strong&gt;1.91:1&lt;/strong&gt;. Nothing warned us, because nothing in that pipeline treats a wrongly shaped image as an error.&lt;/p&gt;

&lt;p&gt;Before building a replacement we measured nine other browser-game portals to see what a correct one looks like in the wild. &lt;strong&gt;Six of the nine served a measurable image, and exactly one of those matched both the card type it declared and its own declared dimensions.&lt;/strong&gt; Ours was the worst number in the table.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The short version.&lt;/strong&gt; A social card has a fixed shape. Your image gets fitted into it and the remainder is discarded — silently, with no error and no console message. So the defect is invisible from inside your own site: the page validates, the tag is present, the image loads. You only ever see it on somebody else's platform, in a preview you are not looking at.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What shape is an og:image supposed to be?
&lt;/h2&gt;

&lt;p&gt;It depends on one other tag, which is the part people miss. &lt;code&gt;twitter:card&lt;/code&gt; chooses which of two layouts a platform uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;summary_large_image&lt;/code&gt; — the big banner card. It renders at roughly &lt;strong&gt;1.91:1&lt;/strong&gt;, and &lt;strong&gt;1200 × 630&lt;/strong&gt; is the conventional size to supply.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;summary&lt;/code&gt; — the small card with a thumbnail beside the text. Roughly square.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Declaring one and supplying an image shaped for the other is the whole bug. And because the platform fits rather than rejects, both mistakes look completely fine on your end. The tag is there. The image resolves. Your validator is happy. Somewhere else, a person sees the middle third of your wordmark.&lt;/p&gt;

&lt;p&gt;Worth saying plainly, since these tags have a reputation for being fiddly: the ratio is what matters, not the exact pixel count. A 2400 × 1260 image is the same shape as a 1200 × 630 one and previews identically.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was our own site actually serving?
&lt;/h2&gt;

&lt;p&gt;We read the number out of the PNG's own &lt;code&gt;IHDR&lt;/code&gt; header rather than trusting the filename or the CSS, and by two independent routes — once off the file on disk, once by fetching the first 16 KB of the live URL. Both gave the same answer.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The file we were serving as &lt;code&gt;og:image&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the site wordmark, 36,527 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Its real pixel size&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1695 × 387&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Its aspect ratio&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.380 : 1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What our declared card renders at&lt;/td&gt;
&lt;td&gt;~&lt;strong&gt;1.905 : 1&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mismatch&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.30×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pages affected&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;23&lt;/strong&gt; of 235&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;og:image:width&lt;/code&gt; / &lt;code&gt;og:image:height&lt;/code&gt; declared&lt;/td&gt;
&lt;td&gt;neither&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 2.3× too wide, more than half the width of the image is cut. What survives is a centre slice of a wordmark with the first and last letters gone — which is worse than no image at all in one specific way: it looks like a rendering bug on the platform, not a choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do other browser-game portals serve?
&lt;/h2&gt;

&lt;p&gt;This is the part we had not seen written down anywhere, so we measured it. One unauthenticated &lt;code&gt;GET&lt;/code&gt; per homepage, the Open Graph tags read out of the served HTML, then a &lt;code&gt;Range: bytes=0-16383&lt;/code&gt; request against each image so we could read its real dimensions out of the file's own header without downloading the whole thing. Measured &lt;strong&gt;12 August 2026&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Site&lt;/th&gt;
&lt;th&gt;Card declared&lt;/th&gt;
&lt;th&gt;og:image, real size&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;th&gt;Dimensions declared in the markup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Poki&lt;/td&gt;
&lt;td&gt;&lt;code&gt;summary_large_image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1200 × 630&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.905&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1200 × 630 — matches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Playgama&lt;/td&gt;
&lt;td&gt;&lt;code&gt;summary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2400 × 1260&lt;/td&gt;
&lt;td&gt;1.905&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CrazyGames&lt;/td&gt;
&lt;td&gt;&lt;code&gt;summary_large_image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1200 × &lt;strong&gt;600&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;2.000&lt;/td&gt;
&lt;td&gt;1200 × &lt;strong&gt;630&lt;/strong&gt; — does not match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Armor Games&lt;/td&gt;
&lt;td&gt;&lt;code&gt;summary_large_image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1200 × 825&lt;/td&gt;
&lt;td&gt;1.455&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Y8&lt;/td&gt;
&lt;td&gt;&lt;code&gt;summary_large_image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;500 × 500&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;500 × 500 — matches the file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;itch.io&lt;/td&gt;
&lt;td&gt;&lt;code&gt;summary_large_image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no og:image&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kongregate&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;no og:image&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GameJolt&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;no og:image&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Newgrounds&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;not measured&lt;/strong&gt; — returned 403 to our request&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ours, before this&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;summary_large_image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1695 × 387&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.380&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reading it honestly, four things stand out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One site got it exactly right.&lt;/strong&gt; Poki declares the wide card, serves 1200 × 630, and declares dimensions that match the file. That is the whole specification, done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Y8 has our bug, smaller.&lt;/strong&gt; It declares the wide card and serves a &lt;strong&gt;square&lt;/strong&gt; 500 × 500 logo. Same class of mismatch as ours, at 1.9× instead of 2.3×.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A declared dimension can drift from the file.&lt;/strong&gt; CrazyGames' markup says the image is 630 tall; the file the CDN actually delivered to us is &lt;strong&gt;600&lt;/strong&gt;. The image URL itself even asks for &lt;code&gt;height=630&amp;amp;fit=crop&lt;/code&gt;. Thirty pixels is cosmetically nothing — but it is a live example of a hardcoded number and a real file disagreeing, which is the argument for generating those tags rather than typing them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three of nine ship no og:image on the homepage at all&lt;/strong&gt;, and itch.io declares &lt;code&gt;summary_large_image&lt;/code&gt; while supplying nothing to put in it. A card with a hole in it.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What this table is not.&lt;/strong&gt; It is not a ranking, and it is not a criticism of anyone. Every site in it is a larger platform than ours, and the worst number in the table is &lt;em&gt;ours&lt;/em&gt;. It is also a single unauthenticated request per homepage on one day: a site can serve different markup to a logged-in visitor or to a specific crawler, inner pages often differ from the homepage, and Newgrounds returned 403 to us so it is recorded as unmeasured rather than guessed at. The point of the table is only this — &lt;strong&gt;if a defect this visible is this common among sites this large, checking yours is worth five minutes.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why did measuring first cut the job by ninety per cent?
&lt;/h2&gt;

&lt;p&gt;The instinct on finding a broken share image is to build a card and put it on every page. We have 235 pages, so that is 235 edits.&lt;/p&gt;

&lt;p&gt;Counting first said otherwise. &lt;strong&gt;212 of the 235 were never part of the defect.&lt;/strong&gt; Those pages point &lt;code&gt;og:image&lt;/code&gt; at the individual game's own thumbnail rather than at any site-level image. We sampled 25 of the 200 catalogue thumbnails at random by range request, and &lt;strong&gt;25 of 25 came back 1920 × 1080&lt;/strong&gt;; a full sweep of all 200 resolved 196 at that size and left 4 unresolved.&lt;/p&gt;

&lt;p&gt;16:9 is 1.778:1. Into a 1.91:1 card that is a trivial crop — a few per cent off the top and bottom. Those pages were fine.&lt;/p&gt;

&lt;p&gt;They were also &lt;em&gt;better&lt;/em&gt; than fine. A preview showing the actual game tells a reader what they are about to click; a brand card tells them the domain, which the platform already prints underneath. Putting a generic card on 212 game pages would have been a downgrade dressed up as a fix — the kind you never detect afterwards, because everything you changed still validates.&lt;/p&gt;

&lt;p&gt;So 23 pages needed the new card, not 235. The general rule we took from it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Count how many things actually have the defect before you build the thing that repairs it.&lt;/strong&gt; The audit is not preparation for the fix. It is the first draft of the fix, and it is the step that stops a repair doing damage of its own.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why we did not declare width and height on every page
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;og:image:width&lt;/code&gt; and &lt;code&gt;og:image:height&lt;/code&gt; let a platform lay out the preview before it has fetched the image. That is a genuine benefit and we do declare them — on the 23 pages using our own card.&lt;/p&gt;

&lt;p&gt;We deliberately did &lt;strong&gt;not&lt;/strong&gt; add them to the 212 pages whose images live on someone else's CDN. Of 200 thumbnails, &lt;strong&gt;4 did not resolve&lt;/strong&gt;: three whose dimensions were not inside the first 8 KB, and one that timed out. Writing &lt;code&gt;1920 × 1080&lt;/code&gt; on all 200 would mean publishing a number we had not verified about a remote file that can change without telling us. Leaving the tags off costs nothing, because platforms measure the image once they fetch it.&lt;/p&gt;

&lt;p&gt;Our own card's tags are safe for a different reason: the build reads them &lt;strong&gt;out of the PNG header at generation time&lt;/strong&gt;, so the declared number cannot drift from the asset. Which is exactly the failure mode the CrazyGames row above is a live example of.&lt;/p&gt;

&lt;p&gt;The underlying principle is the same one that governs every other number we publish: &lt;strong&gt;a dimension you have not measured is a claim, not a fact.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why was it the hand-written pages again?
&lt;/h2&gt;

&lt;p&gt;All &lt;strong&gt;17&lt;/strong&gt; of our blog posts carried the wide wordmark. That is not a coincidence and it is not carelessness — it is the same structural hole that had just cost us three untagged pages, described in &lt;a href="https://dev.to/liza_18827c15bdd387c4cd9c/why-3-of-our-16-blog-posts-had-no-analytics-tag-kc1"&gt;an earlier post about missing analytics tags&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The mechanism, in one sentence: a hand-authored page starts life as a copy of an older page's &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, so it inherits whatever that head contained — and the generator deliberately never overwrites a file a human wrote, so the template can never reach in and correct it. Every hand-written page is a snapshot of your head element on the day it was created.&lt;/p&gt;

&lt;p&gt;The repair is a build step rather than a checklist item, because a checklist still depends on somebody remembering on file number eighteen. It is deliberately narrow: it only touches a page whose &lt;code&gt;og:image&lt;/code&gt; is one of our own logo files. Give a post a real cover image of its own and it is left completely alone.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A gap we found while writing this, and have not closed.&lt;/strong&gt; All 17 posts now serve the new card as their &lt;code&gt;og:image&lt;/code&gt; — but the &lt;code&gt;BlogPosting&lt;/code&gt; structured data on those same 17 pages still names the 1695 × 387 wordmark as its &lt;code&gt;image&lt;/code&gt;. Two tags on one page, disagreeing about what the page's image is. The repair covered the one we went looking at and not the one beside it. We are writing it down rather than quietly fixing it later, because the whole argument of this article is that the assumption is the expensive part: we assumed a fix aimed at &lt;code&gt;og:image&lt;/code&gt; had covered the page's images. It had not. This post uses the card in both.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What is worse than a cropped preview?
&lt;/h2&gt;

&lt;p&gt;A missing one. A cropped image looks like a glitch; a preview with no image at all looks like a dead link, and it is the exact failure you invite when you repoint every page at a brand-new file that has not shipped yet.&lt;/p&gt;

&lt;p&gt;So the card is protected three separate ways, none of which trusts the others:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The build &lt;strong&gt;throws a fatal error&lt;/strong&gt; if the card is missing from source, before it writes anything.&lt;/li&gt;
&lt;li&gt;The generator &lt;strong&gt;copies the card into the output&lt;/strong&gt; as part of the build rather than relying on someone to remember it.&lt;/li&gt;
&lt;li&gt;The bundle assembler &lt;strong&gt;asserts that every same-host &lt;code&gt;og:image&lt;/code&gt; resolves to a real file&lt;/strong&gt; in the bundle before it can be shipped.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And each of those assertions was &lt;strong&gt;proved able to fail&lt;/strong&gt; — run against a deliberately broken copy of the site to confirm it catches the break, then against the clean one to confirm it still passes. A check that has never failed is not a check; it is a line of code you feel good about.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you check yours in five minutes?
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read your own markup from outside.&lt;/strong&gt; &lt;code&gt;curl -s https://yoursite.com/ | grep -o 'og:image[^&amp;gt;]*'&lt;/code&gt; — and note your &lt;code&gt;twitter:card&lt;/code&gt; value in the same pass, because the two only make sense together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure the file, not the CSS.&lt;/strong&gt; Fetch the first 16 KB and read the header. For a PNG that is four bytes at a known offset:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;
&lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IMG_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bytes=0-16383&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;II&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;      &lt;span class="c1"&gt;# PNG IHDR
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Divide.&lt;/strong&gt; Anything outside roughly &lt;strong&gt;1.7 – 2.1&lt;/strong&gt; will visibly crop in a wide card. A wordmark will be somewhere around 4. A square logo will be 1.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the image resolves from outside your network&lt;/strong&gt;, on a clean request. A 404 here is worse than a bad ratio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you declare width and height, confirm they match the file.&lt;/strong&gt; They drift, as the table above shows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then do your second-most-shared page type, not just the homepage.&lt;/strong&gt; This is the step that actually pays. Ours was fine on 212 pages and broken on 23 — and the 23 were the ones anybody shares.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control your own probe.&lt;/strong&gt; Point the same script at a URL you know is not an image and at one you know does not exist. If it returns a confident answer for both, it is not measuring anything.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last one is not padding. The reason this defect lived on our site for as long as it did is that everything we had ever pointed at it reported success.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we could not verify
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Newgrounds is unmeasured.&lt;/strong&gt; It returned 403 to our request. That is our access, not their markup, and we are not going to guess a row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We measured homepages, once, on one day.&lt;/strong&gt; Inner pages commonly differ, and a site may serve different markup to a logged-in visitor or a named crawler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether any of this improves click-through is unknown&lt;/strong&gt;, and we are not going to claim it. We have no before-baseline for share-card clicks, so the honest status is unmeasured rather than improved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix is not live on our own site yet.&lt;/strong&gt; It is complete and verified in our deploy bundle, but the machine that built it has no deploy path, so as of publishing this the live site still serves the 4.38:1 wordmark — we re-checked, and the new card returns 404 on live. Saying so is simpler than the alternative, and you can verify it yourself with the &lt;code&gt;curl&lt;/code&gt; in step 1.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Written while rebuilding the share cards on &lt;a href="https://slowden.com/" rel="noopener noreferrer"&gt;SlowDen&lt;/a&gt;, a place to put a browser game where people can find it and play it — no download for the player, no store page for you. Games there come from creators, partners and third-party sources, and each one is credited to whoever made it. If you have something playable, you can &lt;a href="https://slowden.com/submit/" rel="noopener noreferrer"&gt;submit it&lt;/a&gt; or take a &lt;a href="https://slowden.com/slow-cook/" rel="noopener noreferrer"&gt;Slow Cook&lt;/a&gt; prompt: selected entries have a chance to be featured and selected winners may receive a shoutout, which is the honest list and there is nothing guaranteed beyond it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>html</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why 3 of our 16 blog posts had no analytics tag</title>
      <dc:creator>Liza Bhadana</dc:creator>
      <pubDate>Wed, 12 Aug 2026 05:41:04 +0000</pubDate>
      <link>https://dev.to/liza_18827c15bdd387c4cd9c/why-3-of-our-16-blog-posts-had-no-analytics-tag-kc1</link>
      <guid>https://dev.to/liza_18827c15bdd387c4cd9c/why-3-of-our-16-blog-posts-had-no-analytics-tag-kc1</guid>
      <description>&lt;p&gt;Three of our sixteen blog posts were about to ship with &lt;strong&gt;no analytics tag at all&lt;/strong&gt;. Not a broken tag, not a wrong measurement ID — the snippet was simply not in the file. The pages rendered correctly, validated, carried their canonical link and their structured data, and would have returned a perfectly ordinary &lt;code&gt;200&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cause is one line of reasonable behaviour: &lt;strong&gt;our page generator refuses to overwrite a file a human wrote.&lt;/strong&gt; That refusal is correct. Its side effect is that the one page type written by a human is the one page type the head template cannot reach.&lt;/p&gt;

&lt;p&gt;Nobody found this by reading a page. It was found by a build check that had never existed before, and it had already happened three times.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The short version&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;215 of our 235 shipping pages&lt;/strong&gt; get the analytics tag from a template function. The other &lt;strong&gt;20&lt;/strong&gt; get it from a person remembering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 of those 20 did not have it.&lt;/strong&gt; All three were hand-written blog posts.&lt;/li&gt;
&lt;li&gt;The other 13 posts had it because someone remembered &lt;strong&gt;13 times in a row&lt;/strong&gt;. That is a streak, not a fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing on the page looks wrong.&lt;/strong&gt; The failure is an absence in a report, not a defect you can see.&lt;/li&gt;
&lt;li&gt;The fix is a &lt;strong&gt;build step, not a checklist item&lt;/strong&gt;: insert the tag into any hand-authored post that lacks it, and never touch one that has it.&lt;/li&gt;
&lt;li&gt;Two probes lied to us during the repair. Both are reproducible in three lines, and both are near the end of this post.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why did only the blog posts lose the tag?
&lt;/h2&gt;

&lt;p&gt;Our site is static. A Node script reads a registry of games and writes every crawlable page: game pages, category pages, the submission page, the challenge page, the blog index. Every one of those goes through a single function that builds the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; — title, description, canonical, Open Graph, favicon, fonts, and the analytics snippet.&lt;/p&gt;

&lt;p&gt;Blog posts do not go through it. They are hand-written HTML, and the generator carries an explicit instruction not to touch them, sitting right above the list of posts:&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;// Posts are listed by hand here so the generator never overwrites&lt;/span&gt;
&lt;span class="c1"&gt;// the hand-authored post HTML.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BLOG_POSTS&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="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That comment is not a mistake. Regenerating a hand-written article would destroy the article. But read it as a sentence about analytics and it says something else: &lt;em&gt;the head template cannot reach these files.&lt;/em&gt; Anything the template guarantees — the tag, and anything else you later add to it — stops at the boundary.&lt;/p&gt;

&lt;p&gt;So the tag arrived by hand, thirteen times, and then did not arrive three times. There was no moment of carelessness that anyone could point at. The mechanism simply had no floor under it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which pages does a template actually protect?
&lt;/h2&gt;

&lt;p&gt;We counted, because the ratio turned out to be the whole story. Of 235 pages this site ships:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How the page is produced&lt;/th&gt;
&lt;th&gt;Pages&lt;/th&gt;
&lt;th&gt;Where the tag comes from&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Built by the generator from the head template&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;215&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The template. Cannot be forgotten.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copied verbatim from a hand-maintained folder&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;A person, once, in the source file.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mirrored from the hand-edited homepage&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;A person, once, in that file.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hand-authored blog posts&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A person, &lt;strong&gt;every single time&lt;/strong&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 215 are safe by construction. The 4 in the middle rows are safe in practice, because they are edited rarely and the tag is already sitting in them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 16 in the last row are the exposure&lt;/strong&gt;, and it is not a coincidence that the failures were all there. It is the only row where the human step repeats. A rule that has to be re-obeyed on every new file will eventually not be, and the number of chances is the number of files you plan to write.&lt;/p&gt;

&lt;p&gt;Worth naming the general shape, because almost every static site has all three: &lt;strong&gt;generated pages, copied pages, and hand-written pages.&lt;/strong&gt; Whatever you believe is on every page of your site is only guaranteed on the first kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you find untracked pages on your own site?
&lt;/h2&gt;

&lt;p&gt;The check is trivial. Search your built output for your measurement ID and list every file that does not contain it. Ours reported three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3 page(s) missing the analytics tag:
    blog/the-same-url-served-two-different-images/index.html
    blog/the-star-rating-your-iframe-added/index.html
    blog/the-game-metadata-field-nobody-fills-in/index.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things make the difference between that check being useful and it being theatre.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Audit what you ship, not what your build folder contains
&lt;/h3&gt;

&lt;p&gt;Our output directory holds &lt;strong&gt;347 HTML files&lt;/strong&gt;. Only &lt;strong&gt;235&lt;/strong&gt; of them ship. The other 112 are directories for retired pages the generator has never deleted, plus one snippet file that is not a page at all — it is a block of HTML meant to be pasted into another document.&lt;/p&gt;

&lt;p&gt;Run the audit over the folder and 112 of your results describe files no visitor can reach. Worse, the noise runs both ways: a retired page that &lt;em&gt;does&lt;/em&gt; carry the tag inflates your pass rate, and a snippet that legitimately has no &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; reports as a failure forever. Build the list of pages you actually ship, and audit that.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Break the check on purpose before you believe it
&lt;/h3&gt;

&lt;p&gt;A check that has never failed is not a check — you have no evidence it can detect anything. So before trusting ours, we deleted the analytics snippet out of one post and re-ran the build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;md5 before strip : 187fbf210f520e496e97f082c41cf25b
tag occurrences  : 0          &amp;lt;- deliberately broken
build            : "inserted the analytics tag into
                    hand-authored /blog/the-star-rating-your-iframe-added/"
md5 after repair : 187fbf210f520e496e97f082c41cf25b   &amp;lt;- identical
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single test proves three separate things at once: the check notices, the repair works, and the repair is &lt;strong&gt;byte-for-byte non-destructive&lt;/strong&gt;. A hand-written article is exactly the kind of file where "it probably re-wrote it correctly" is not good enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed, and why it is not a checklist item
&lt;/h2&gt;

&lt;p&gt;The tempting fix is a note in the authoring instructions: &lt;em&gt;remember to paste the snippet&lt;/em&gt;. That is what we effectively had, and it produced thirteen successes and three silent failures.&lt;/p&gt;

&lt;p&gt;Instead, the generator now repairs the gap itself. After it writes the blog index, it walks the list of posts and, for any post whose file does not already contain the measurement ID, inserts the same snippet every generated page uses, immediately before the closing &lt;code&gt;&amp;lt;/head&amp;gt;&lt;/code&gt;. Four properties made it safe enough to run unattended on hand-written prose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Insert-only.&lt;/strong&gt; It never rewrites, reformats, re-indents or removes a byte of the article. It splits the file at one index and puts a string in the gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent.&lt;/strong&gt; A post that already carries the tag is not opened for writing at all, so it is not restamped as "changed" in our sitemap date manifest and does not falsely claim to have been updated today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loud.&lt;/strong&gt; Every repair prints a line naming the post. A silent self-healing step is just a slower version of the same problem, because nobody learns the authoring habit is drifting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fails hard on the case it cannot fix.&lt;/strong&gt; If a post is listed but its file is missing, the build stops with an error instead of continuing. A listed-but-missing post would put a dead URL in the sitemap and a dead card on the blog index — a 404 shipped on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same pattern already existed elsewhere in this generator, for a different hand-edited file that had gone stale nine times in five weeks. That is the tell: when the second instance of a class of bug appears, the fix is a step in the build, not a better memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Untracked is not the same as unpublished
&lt;/h2&gt;

&lt;p&gt;An awkward detail, recorded because it is the reason this cost nothing. The same three posts were &lt;em&gt;also&lt;/em&gt; the three not yet deployed — they return 404 on the live site right now, verified against a deliberate nonsense URL that returned an identical 404 body. So no real visit went unmeasured.&lt;/p&gt;

&lt;p&gt;That overlap is luck. The two failures are unrelated and they behave in opposite ways:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Unpublished page&lt;/th&gt;
&lt;th&gt;Untracked page&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it returns&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How it looks&lt;/td&gt;
&lt;td&gt;Obviously broken&lt;/td&gt;
&lt;td&gt;Perfect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How you find out&lt;/td&gt;
&lt;td&gt;Immediately&lt;/td&gt;
&lt;td&gt;Weeks later, in a report&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What it looks like when you do&lt;/td&gt;
&lt;td&gt;A missing page&lt;/td&gt;
&lt;td&gt;A page nobody visited&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last cell is the expensive one. An untracked page does not report as an error, it reports as &lt;strong&gt;indifference&lt;/strong&gt; — and a page that appears to have no audience is a page someone eventually decides not to write again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two probes that lied while we were fixing it
&lt;/h2&gt;

&lt;p&gt;Both of these happened during the repair above, both are on Windows, and both are reproducible in three lines. They are here because each one is the same species of problem as the bug itself: a thing that quietly does not do what it appears to do.&lt;/p&gt;

&lt;h3&gt;
  
  
  A text-mode file read rewrote a file we only meant to inspect
&lt;/h3&gt;

&lt;p&gt;Opening a file in Python's default text mode, reading it, and writing the identical string back is not a no-op on Windows. Line endings are translated on write. A three-line file with Unix endings, read and written unchanged, gained three bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'alpha\nbeta\ngamma\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; lf-control.txt

python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"d=open('lf-control.txt',encoding='utf-8').read();
           open('lf-control.txt','w',encoding='utf-8').write(d)"&lt;/span&gt;

carriage returns before : 0
carriage returns after  : 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On our 253-line article that turned into 253 changed bytes across the whole file, which is why the byte-for-byte check above initially failed. It was not the repair that changed the file; it was the tool used to inspect it. Open in binary mode, or pass &lt;code&gt;newline=''&lt;/code&gt;, when the bytes are the thing you are checking.&lt;/p&gt;

&lt;h3&gt;
  
  
  The probe that said the file was clean returned zero either way
&lt;/h3&gt;

&lt;p&gt;Worse, the obvious way to check for the damage said there was none. Searching the file for a carriage return reported &lt;strong&gt;0&lt;/strong&gt; — on a file that provably contained three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'alpha\r\nbeta\r\ngamma\r\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; crlf-control.txt

&lt;span class="nb"&gt;od&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; crlf-control.txt | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'\r'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;   -&amp;gt; 3   &lt;span class="o"&gt;(&lt;/span&gt;they are there&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;  crlf-control.txt                 -&amp;gt; 0   &lt;span class="o"&gt;(&lt;/span&gt;says clean&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;  lf-control.txt                   -&amp;gt; 0   &lt;span class="o"&gt;(&lt;/span&gt;also says clean&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-cU&lt;/span&gt; &lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt; crlf-control.txt                 -&amp;gt; 3   &lt;span class="o"&gt;(&lt;/span&gt;correct&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;-U&lt;/code&gt;, the file is treated as text and the carriage return is stripped before the pattern is applied, so the search can never match. Note the third line: &lt;strong&gt;the probe returns 0 on a file that has them and 0 on a file that does not.&lt;/strong&gt; It is not inaccurate, it is inert — there is no input that would make it say anything else.&lt;/p&gt;

&lt;p&gt;That is the general rule worth taking away, and it is the same rule that found the original bug. &lt;strong&gt;Run your checker against an input you know should fail.&lt;/strong&gt; If it passes that too, the checker is not measuring anything, and every green result you have collected from it means nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check yours, in about ten minutes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write down your ship list&lt;/strong&gt; — the pages a visitor can actually reach. Not your build folder, and not everything your generator has ever emitted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep that list for your measurement ID&lt;/strong&gt; and print the files that do not contain it. Expect the count to be greater than zero the first time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sort the misses by how the page was produced.&lt;/strong&gt; If they cluster in one production path, you have found a structural gap, not three mistakes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete the tag from one page and re-run.&lt;/strong&gt; If the check still passes, fix the check before you trust a single result from it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move the guarantee into the build&lt;/strong&gt;, and make it print a line when it repairs something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repeat with anything else your template promises&lt;/strong&gt; — canonical links, viewport meta, structured data, consent scripts. They all stop at exactly the same boundary.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What we could not verify
&lt;/h2&gt;

&lt;p&gt;Stated plainly, because a post that only reports its wins is not worth much:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;How long the gap existed before it was found.&lt;/strong&gt; The three posts were written on 7, 8 and 11 August 2026 and the check was added on 12 August, but we did not reconstruct the exact authoring history of the other thirteen, so "someone remembered thirteen times" is inferred from the files' contents rather than from a record of each edit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether any traffic was lost.&lt;/strong&gt; None of the three had been deployed, so the answer is almost certainly no — but "almost certainly" is the honest phrasing, not "none".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether the same class of gap exists elsewhere on this site.&lt;/strong&gt; We audited the analytics tag specifically. The other things the head template guarantees have not been swept yet, and the point of this post is that they have exactly the same boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The line-ending and grep behaviour on other platforms.&lt;/strong&gt; Both were measured on Windows with the tooling in front of us. We did not test macOS or Linux, and are not claiming anything about them.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dev.to/liza_18827c15bdd387c4cd9c/the-same-url-served-me-two-different-images-pm4"&gt;The same URL served me two different images&lt;/a&gt; — one of the three untagged posts, and another case of a request doing something the URL did not say.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/liza_18827c15bdd387c4cd9c/the-star-rating-your-iframe-added-to-your-page-1hj6"&gt;The star rating your iframe added to your page&lt;/a&gt; — structured data you did not write, attributed to you.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://slowden.com/blog/why-players-leave-before-your-game-starts/" rel="noopener noreferrer"&gt;Why players leave before your game starts&lt;/a&gt; — what the numbers look like when you finally can measure.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://slowden.com/blog/why-your-game-breaks-inside-an-iframe/" rel="noopener noreferrer"&gt;Why your game breaks inside an iframe&lt;/a&gt; — another set of defaults nobody chose.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Written while working on &lt;a href="https://slowden.com/" rel="noopener noreferrer"&gt;SlowDen&lt;/a&gt;, a place to put a browser game where people can find it and play it — no download for the player, no store page for you. Games there come from creators, partners and third-party sources, and each one is credited to whoever made it. If you are building something, &lt;a href="https://slowden.com/slow-cook/" rel="noopener noreferrer"&gt;Slow Cook&lt;/a&gt; is the creative challenge round: you get a prompt, you build around it, and you &lt;a href="https://slowden.com/submit/" rel="noopener noreferrer"&gt;submit it for consideration&lt;/a&gt;. Selected entries have a chance to be featured and selected winners may receive a shoutout — that is the honest list, there is nothing guaranteed beyond it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>analytics</category>
      <category>testing</category>
      <category>seo</category>
    </item>
    <item>
      <title>The same URL served me two different images</title>
      <dc:creator>Liza Bhadana</dc:creator>
      <pubDate>Tue, 11 Aug 2026 05:59:51 +0000</pubDate>
      <link>https://dev.to/liza_18827c15bdd387c4cd9c/the-same-url-served-me-two-different-images-pm4</link>
      <guid>https://dev.to/liza_18827c15bdd387c4cd9c/the-same-url-served-me-two-different-images-pm4</guid>
      <description>&lt;p&gt;I fetched one image URL twice, in the same browser, on the same page, about a second apart.&lt;/p&gt;

&lt;p&gt;The first fetch returned &lt;strong&gt;2,864,826 bytes of PNG at 1920×1080&lt;/strong&gt;.&lt;br&gt;
The second returned &lt;strong&gt;115,500 bytes of AVIF at 512×288&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Same URL. Same machine. Same session. Nothing cached differently, nothing random.&lt;/p&gt;

&lt;p&gt;The difference was one request header I never wrote, and would not have thought to look at.&lt;/p&gt;

&lt;p&gt;The URL also carries &lt;code&gt;?width=512&lt;/code&gt;, which looks like it is asking for a small image. It is not. That parameter does nothing at all.&lt;/p&gt;

&lt;p&gt;Everything below was measured on 2026-08-11 against a live CDN, with negative controls.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 1: the parameter that does nothing
&lt;/h2&gt;

&lt;p&gt;Every game thumbnail in the catalogue I work on is stored on a partner CDN, and every URL ends the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://static.playgama.com/p-img/pg/hedgies/preview/fe27263…?width=512
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;?width=512&lt;/code&gt;. That reads like a resize instruction. So the first thing worth doing is the thing almost nobody does: change it and see if anything happens.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query string&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Bytes returned&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;(no query at all)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;206&lt;/td&gt;
&lt;td&gt;2,864,826&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;?width=512&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;206&lt;/td&gt;
&lt;td&gt;2,864,826&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;?width=256&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;206&lt;/td&gt;
&lt;td&gt;2,864,826&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;?width=64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;206&lt;/td&gt;
&lt;td&gt;2,864,826&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;?width=999999&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;206&lt;/td&gt;
&lt;td&gt;2,864,826&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;?width=abc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;206&lt;/td&gt;
&lt;td&gt;2,864,826&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;?width=0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;206&lt;/td&gt;
&lt;td&gt;2,864,826&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;?wibble=512&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;206&lt;/td&gt;
&lt;td&gt;2,864,826&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Byte-identical, every time. The parameter is decoration. &lt;code&gt;?width=abc&lt;/code&gt; and &lt;code&gt;?width=0&lt;/code&gt; are not errors, they are simply ignored, and so is a parameter name that does not exist.&lt;/p&gt;

&lt;p&gt;That last row matters more than it looks. If I had only tested &lt;code&gt;?width=512&lt;/code&gt; against &lt;code&gt;?width=256&lt;/code&gt; and seen no change, I could have told myself the CDN was clamping to some minimum. Adding a parameter the CDN has certainly never heard of proves the simpler thing: &lt;strong&gt;nothing in the query string is being read.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A checker that cannot fail is not a checker. So I also corrupted the path — same URL with &lt;code&gt;/preview-NOT-REAL/&lt;/code&gt; spliced in — and got a clean &lt;strong&gt;404&lt;/strong&gt;. Good. The probe can tell yes from no.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway for your own code:&lt;/strong&gt; before you build a responsive image pipeline on top of somebody else's URL parameter, feed it nonsense. A parameter that returns identical bytes for &lt;code&gt;512&lt;/code&gt;, &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;abc&lt;/code&gt; and a made-up name is not a knob. It is a comment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: the header that does everything
&lt;/h2&gt;

&lt;p&gt;If the URL is not choosing the image, something else is. It turned out to be &lt;code&gt;Accept&lt;/code&gt; — the header your browser sends to say which formats it can decode, and which you almost never set by hand.&lt;/p&gt;

&lt;p&gt;Same URL, three requests, only the &lt;code&gt;Accept&lt;/code&gt; header changed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;Accept&lt;/code&gt; sent&lt;/th&gt;
&lt;th&gt;Format returned&lt;/th&gt;
&lt;th&gt;Bytes&lt;/th&gt;
&lt;th&gt;Pixels&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;image/jpeg,image/png,*/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PNG&lt;/td&gt;
&lt;td&gt;2,864,826&lt;/td&gt;
&lt;td&gt;1920 × 1080&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;image/webp,image/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;WebP&lt;/td&gt;
&lt;td&gt;189,832&lt;/td&gt;
&lt;td&gt;512 × 288&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;image/avif,image/webp,image/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AVIF&lt;/td&gt;
&lt;td&gt;115,500&lt;/td&gt;
&lt;td&gt;512 × 288&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things are happening at once here, and it is worth separating them.&lt;/p&gt;

&lt;p&gt;The first is the obvious one: &lt;strong&gt;format&lt;/strong&gt;. AVIF beats WebP beats PNG, which is the whole point of &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Content_negotiation" rel="noopener noreferrer"&gt;content negotiation&lt;/a&gt; and is working exactly as designed.&lt;/p&gt;

&lt;p&gt;The second is the one that surprised me: &lt;strong&gt;resolution&lt;/strong&gt;. The modern variants are not just better-compressed versions of the same picture. They are a different, smaller picture — 512 × 288 against 1920 × 1080. The fallback is the full-size original; the negotiated variants are derivatives capped at 512 px wide.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(That the PNG is "the original upload" is my inference from its size and dimensions, not something the CDN told me. What is measured is the pixel count.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So the byte gap is not 3× from better compression. It is &lt;strong&gt;24.8×&lt;/strong&gt;, because format and resolution moved together.&lt;/p&gt;

&lt;p&gt;And critically: &lt;code&gt;Accept: */*&lt;/code&gt; gets you the PNG. So does no &lt;code&gt;Accept&lt;/code&gt; header at all. So does a browser-style HTML accept string. &lt;strong&gt;Wildcards do not opt you in.&lt;/strong&gt; You have to name &lt;code&gt;image/webp&lt;/code&gt; or &lt;code&gt;image/avif&lt;/code&gt; explicitly, and only real image requests from real browsers do that.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: 200 images, not one
&lt;/h2&gt;

&lt;p&gt;One image is an anecdote. So I ran the same three-header probe across every thumbnail in the catalogue — 200 URLs × 3 &lt;code&gt;Accept&lt;/code&gt; headers = 600 requests.&lt;/p&gt;

&lt;p&gt;The trick that makes this cheap: a &lt;code&gt;Range: bytes=0-0&lt;/code&gt; request downloads &lt;strong&gt;one byte&lt;/strong&gt;, but the 206 response still carries &lt;code&gt;Content-Range: bytes 0-0/&amp;lt;total&amp;gt;&lt;/code&gt; — and the CDN still performs content negotiation on it. So one byte per request buys you both the true size and the negotiated format. 600 bytes of transfer instead of roughly 600 MB.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;Accept&lt;/code&gt; sent&lt;/th&gt;
&lt;th&gt;Formats returned&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;th&gt;Mean per image&lt;/th&gt;
&lt;th&gt;Over 1 MB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;image/jpeg,image/png,*/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;188 PNG, 12 JPEG&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;418.9 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2,144.9 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;162 / 200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;image/webp,image/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200 WebP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30.5 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;156.1 KB&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;image/avif,image/webp,image/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200 AVIF&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;19.5 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;100.0 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 / 200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;200 of 200 negotiated successfully. Not one image failed to produce a modern variant.&lt;/p&gt;

&lt;p&gt;The per-image ratio ranges from &lt;strong&gt;2.4× to 48.3×&lt;/strong&gt;, median &lt;strong&gt;22.1×&lt;/strong&gt;. Thirty-five thumbnails are over 3 MB in the fallback path; the largest is 4,017,308 bytes. The smallest is 154 KB — already small enough that negotiation barely helps it.&lt;/p&gt;

&lt;p&gt;That spread is the useful part. There is no single multiplier you can assume. If you need the number, you have to measure the set.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 4: the demo that made it click
&lt;/h2&gt;

&lt;p&gt;Everything above I measured from a terminal, which is exactly the position that produces wrong conclusions about browsers. So I did it from inside the page instead.&lt;/p&gt;

&lt;p&gt;On the live page, in Chrome, I read the rendered thumbnails and then fetched one of the very same URLs from the same page:&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;// what the &amp;lt;img&amp;gt; actually got&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;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;img.tfull&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;naturalWidth&lt;/span&gt;   &lt;span class="c1"&gt;// 512&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;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;img.tfull&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;naturalHeight&lt;/span&gt;  &lt;span class="c1"&gt;// 288&lt;/span&gt;

&lt;span class="c1"&gt;// same URL, same page, one second later&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://static.playgama.com/p-img/pg/hedgies/preview/fe27263…?width=512&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;          &lt;span class="c1"&gt;// 2864826&lt;/span&gt;
&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// "image/png"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag got a 115 KB AVIF. &lt;code&gt;fetch()&lt;/code&gt; got a 2.8 MB PNG. One page, one browser, one URL, two completely different images.&lt;/p&gt;

&lt;p&gt;Nothing is broken. &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; sends the browser's image &lt;code&gt;Accept&lt;/code&gt; list. &lt;code&gt;fetch()&lt;/code&gt; defaults to &lt;code&gt;*/*&lt;/code&gt;. They are two different clients that happen to live in the same tab.&lt;/p&gt;

&lt;p&gt;Once you have seen that, a whole category of confusing bug reports resolves itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 5: why you cannot see this from your own page
&lt;/h2&gt;

&lt;p&gt;The natural next move is to measure it with the Resource Timing API and put it on a dashboard. That does not work here, and the reason is worth knowing.&lt;/p&gt;

&lt;p&gt;All 200 thumbnail entries came back with &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/encodedBodySize" rel="noopener noreferrer"&gt;&lt;code&gt;encodedBodySize&lt;/code&gt;&lt;/a&gt; &lt;strong&gt;= 0&lt;/strong&gt;. Not "small". Zero. Every one.&lt;/p&gt;

&lt;p&gt;That is not a bug either. Size fields on cross-origin resources are hidden unless the responding server sends &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Timing-Allow-Origin" rel="noopener noreferrer"&gt;&lt;code&gt;Timing-Allow-Origin&lt;/code&gt;&lt;/a&gt;. This CDN does not, so the numbers are withheld from the page.&lt;/p&gt;

&lt;p&gt;The headers the page &lt;em&gt;can&lt;/em&gt; read are the CORS-safelisted four: &lt;code&gt;content-type&lt;/code&gt;, &lt;code&gt;content-length&lt;/code&gt;, &lt;code&gt;cache-control&lt;/code&gt;, &lt;code&gt;last-modified&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;your own real-user monitoring cannot measure the weight of the third-party images on your own page.&lt;/strong&gt; Third-party image weight is invisible from the inside by default. It has to be measured from outside, deliberately, which is why it tends not to be measured at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 6: where this actually bites
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Your &lt;code&gt;og:image&lt;/code&gt;.&lt;/strong&gt; Every one of our 200 game pages sets &lt;code&gt;og:image&lt;/code&gt; to one of these URLs. Anything that fetches it while sending &lt;code&gt;*/*&lt;/code&gt; receives the 1920 × 1080 PNG — up to 4 MB — where a visitor to the same page receives ~100 KB.&lt;/p&gt;

&lt;p&gt;I want to be careful here, because it is easy to overclaim. &lt;strong&gt;What I measured is the header, not the crawler.&lt;/strong&gt; I confirmed that a client sending &lt;code&gt;*/*&lt;/code&gt; gets the PNG. I could not verify what any particular social crawler actually sends, because I cannot make requests from their infrastructure. If your preview images matter to you, the honest move is to check your own logs, not to trust my guess or anyone else's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your CI budget.&lt;/strong&gt; A page-weight check that fetches images with a plain HTTP client is measuring the fallback path. It can fail a build over 400 MB of images that no user will ever download — or, if you set the budget from that number, quietly pass everything forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anything that proxies or re-hosts images.&lt;/strong&gt; Image proxies, email pipelines, RSS-to-newsletter tools, thumbnail caches, LLM ingestion — they mostly send &lt;code&gt;*/*&lt;/code&gt;, and they will pull and store the heavy original.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anything that re-encodes.&lt;/strong&gt; If you pull the fallback and re-encode it to WebP yourself, you are doing a lot of work to arrive somewhere near where the CDN would have put you for free.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 7: the defect this found on my own site
&lt;/h2&gt;

&lt;p&gt;Measuring this exposed something that had nothing to do with the CDN, and it was mine.&lt;/p&gt;

&lt;p&gt;The homepage renders every game as a card across twelve category rails. Reading the DOM on the live site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;274&lt;/strong&gt; &lt;code&gt;&amp;lt;img class="tfull"&amp;gt;&lt;/code&gt; elements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0&lt;/strong&gt; with a &lt;code&gt;loading&lt;/code&gt; attribute&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0&lt;/strong&gt; with &lt;code&gt;decoding&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0&lt;/strong&gt; with &lt;code&gt;srcset&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;200 distinct thumbnail requests fired on a single first load&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every thumbnail in the catalogue, requested immediately, including rails several screens below the fold. At the measured AVIF mean that is roughly &lt;strong&gt;19.5 MB of image on first paint&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Computed, not directly observed — &lt;code&gt;Timing-Allow-Origin&lt;/code&gt; is absent, so the browser would not tell me the sizes. It is the sum of the per-URL sizes I measured separately, for the exact 200 URLs the page requested.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The irritating part: the &lt;strong&gt;generated&lt;/strong&gt; pages had this right all along. The page generator has always emitted &lt;code&gt;loading="lazy"&lt;/code&gt; past the first six cards. The homepage is the one file that is hand-edited rather than generated, and it never got the attribute.&lt;/p&gt;

&lt;p&gt;The fix is three tokens — eager for the first six cards of the first rail, &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#loading" rel="noopener noreferrer"&gt;&lt;code&gt;loading="lazy"&lt;/code&gt;&lt;/a&gt; and &lt;code&gt;decoding="async"&lt;/code&gt; for the rest:&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="s2"&gt;`&amp;lt;img class="tfull" src="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thumbnail&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" alt="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;eager&lt;/span&gt; &lt;span class="p"&gt;?&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; loading="lazy"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; decoding="async" onerror="this.remove()"&amp;gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measured in a browser, before and after, on the same build:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Distinct thumbnail requests on first paint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;After scrolling 1000 px it had loaded 50 — which is the point. The images still arrive; they arrive when the reader is actually heading towards them.&lt;/p&gt;

&lt;p&gt;No layout shift, either, and not by luck: &lt;code&gt;.thumb&lt;/code&gt; already carries &lt;code&gt;aspect-ratio: 16/10&lt;/code&gt;, so every card's box is reserved before its image exists. A lazy image drops into a slot that is already the right size. &lt;strong&gt;If your grid does not reserve the box, add &lt;code&gt;loading="lazy"&lt;/code&gt; and you will trade page weight for layout shift&lt;/strong&gt; — fix the box first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 8: how to check yours, in about five minutes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Break the URL parameter on purpose.&lt;/strong&gt; Request your image with &lt;code&gt;?width=64&lt;/code&gt;, &lt;code&gt;?width=abc&lt;/code&gt;, &lt;code&gt;?width=0&lt;/code&gt; and a parameter name you invented. Identical bytes every time means the parameter does nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then corrupt the path&lt;/strong&gt;, and confirm you get a 404. If that also returns 200, your probe is broken and every result above it is meaningless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send three &lt;code&gt;Accept&lt;/code&gt; headers&lt;/strong&gt; — &lt;code&gt;*/*&lt;/code&gt;, &lt;code&gt;image/webp,image/*&lt;/code&gt;, &lt;code&gt;image/avif,image/webp,image/*&lt;/code&gt; — and compare &lt;code&gt;Content-Type&lt;/code&gt; and size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the pixels, not just the bytes.&lt;/strong&gt; &lt;code&gt;naturalWidth&lt;/code&gt; in the browser, or the image header. Format and resolution can move together, and only looking at bytes hides that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;Range: bytes=0-0&lt;/code&gt;&lt;/strong&gt; to sweep a whole catalogue for a few hundred bytes of transfer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count your eager images.&lt;/strong&gt; In the console: &lt;code&gt;[...document.images].filter(i =&amp;gt; !i.hasAttribute('loading')).length&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What I could not verify
&lt;/h2&gt;

&lt;p&gt;Stated plainly, because a measurement piece that only reports its wins is not worth much:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What real social crawlers send.&lt;/strong&gt; I tested &lt;code&gt;Accept&lt;/code&gt; headers, not crawlers. Any claim about a specific platform's preview fetch would be a guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether the 1920 × 1080 PNG is genuinely the original upload.&lt;/strong&gt; It is consistent with one. The CDN did not tell me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-world impact on visitors.&lt;/strong&gt; The 200 → 6 change is a measured reduction in requests on first paint. I have not yet measured what it does to load time for real people on real connections, and I am not going to claim a number I have not collected.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Related, from the same site
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/liza_18827c15bdd387c4cd9c/200-game-pages-200-star-ratings-0-machine-readable-controls-3jem"&gt;200 game pages, 200 star ratings, 0 machine-readable controls&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/liza_18827c15bdd387c4cd9c/the-star-rating-your-iframe-added-to-your-page-1hj6"&gt;The star rating your iframe added to your page&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://slowden.com/blog/why-your-browser-game-is-unplayable-on-a-phone/" rel="noopener noreferrer"&gt;Why your browser game is unplayable on a phone&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://slowden.com/blog/why-players-leave-before-your-game-starts/" rel="noopener noreferrer"&gt;Why players leave before your game starts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://slowden.com/blog/why-your-game-breaks-inside-an-iframe/" rel="noopener noreferrer"&gt;Why your game breaks inside an iframe&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Measured while running &lt;a href="https://slowden.com/" rel="noopener noreferrer"&gt;SlowDen&lt;/a&gt;, a free browser-game site. The games come from partners and creators, each credited on its own page. If you have a playable browser game you can &lt;a href="https://slowden.com/submit/" rel="noopener noreferrer"&gt;submit it&lt;/a&gt;, or take on a &lt;a href="https://slowden.com/slow-cook/" rel="noopener noreferrer"&gt;Slow Cook&lt;/a&gt; challenge — a creative prompt, a small game, and a chance to be featured.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>html</category>
      <category>programming</category>
    </item>
    <item>
      <title>The star rating your iframe added to your page</title>
      <dc:creator>Liza Bhadana</dc:creator>
      <pubDate>Sat, 08 Aug 2026 16:44:22 +0000</pubDate>
      <link>https://dev.to/liza_18827c15bdd387c4cd9c/the-star-rating-your-iframe-added-to-your-page-1hj6</link>
      <guid>https://dev.to/liza_18827c15bdd387c4cd9c/the-star-rating-your-iframe-added-to-your-page-1hj6</guid>
      <description>&lt;p&gt;Google's Rich Results Test says one of our game pages has &lt;strong&gt;four valid structured-data items&lt;/strong&gt;. We wrote one of them.&lt;/p&gt;

&lt;p&gt;The other three — an Organisation, a Review snippet and a Software App — are published by a document we do not control, do not host, and cannot read from our own page. It arrives in an &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt;. It brings a star rating with it, and Google's own tool reports that rating as ours.&lt;/p&gt;

&lt;p&gt;Nobody did anything wrong to make this happen. Our markup is clean and always has been. The partner's markup is &lt;em&gt;exemplary&lt;/em&gt; — genuinely one of the better structured-data implementations you will read. Put the two together with a normal load-time iframe and you get a third thing that neither party wrote.&lt;/p&gt;

&lt;p&gt;If you embed anything on your site — a game, a video, a map, a booking widget, a review carousel — this is a five-minute check you have probably never run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The short version&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two pages, same template, same day, one difference.&lt;/strong&gt; The page whose iframe points at a document with a JSON-LD graph: &lt;strong&gt;4 valid items&lt;/strong&gt;. The page whose iframe points at a document with none: &lt;strong&gt;1 valid item&lt;/strong&gt;. Same tool, five minutes apart.&lt;/li&gt;
&lt;li&gt;The extra three item types are &lt;strong&gt;Organisation&lt;/strong&gt;, &lt;strong&gt;Review snippets&lt;/strong&gt; and &lt;strong&gt;Software apps&lt;/strong&gt;. The detected Review snippet carries an &lt;code&gt;@id&lt;/code&gt; on the &lt;em&gt;provider's&lt;/em&gt; domain, not ours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0 of 200&lt;/strong&gt; of our live game pages carry a rating in their own markup. &lt;strong&gt;190 of 200&lt;/strong&gt; of the embed documents they load do.&lt;/li&gt;
&lt;li&gt;The Software App item detected on our page is &lt;strong&gt;named after the platform&lt;/strong&gt;, not the game — it is the provider's Android app listing.&lt;/li&gt;
&lt;li&gt;It is not a games-industry quirk, and it is not universal either: of &lt;strong&gt;11&lt;/strong&gt; public embed endpoints we could read, &lt;strong&gt;4&lt;/strong&gt; serve parseable JSON-LD and &lt;strong&gt;1&lt;/strong&gt; serves a rating.&lt;/li&gt;
&lt;li&gt;A text search for the property name is not a test. On &lt;strong&gt;4&lt;/strong&gt; of those embed documents, searching the HTML for &lt;code&gt;aggregateRating&lt;/code&gt; returned &lt;em&gt;true&lt;/em&gt; while the parsed structured data contained no such property.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I measured, and what I am not claiming
&lt;/h2&gt;

&lt;p&gt;Everything below is a count of &lt;strong&gt;published metadata and tool output&lt;/strong&gt;, taken on 8 August 2026. Three honest limits, stated before the numbers rather than after them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I am not claiming Google will show a star rating for our site.&lt;/strong&gt; The Rich Results Test reports &lt;em&gt;eligibility&lt;/em&gt;, and Google states plainly that it does not guarantee rich results even with correct markup. What the tool reports is that the item exists, is valid, and is attributed to our URL. Whether it ever reaches a search result is a different question I did not measure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I am not claiming Google documents this behaviour as a rule.&lt;/strong&gt; I looked. Google's structured-data documentation does not contain a statement of the form "markup inside an iframe is attributed to the parent". What it contains is an &lt;em&gt;acknowledgement&lt;/em&gt; that the situation arises, which I quote further down. Treat the mechanism as measured on one date, on two URLs, with a control — not as a documented guarantee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I could not check whether the rating is visible to a human inside the player.&lt;/strong&gt; That frame is cross-origin, so the host document cannot read into it, and a screen capture of a cross-origin game canvas is not evidence of anything — it comes back solid black even while the game runs perfectly. So the honest statement is narrower than the tempting one: &lt;em&gt;our page's own text shows no rating&lt;/em&gt;, and I verified that directly. What the player renders inside its own frame is unmeasured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One method rule that changed a number in this article.&lt;/strong&gt; Do not search the HTML for a property name; parse the JSON-LD and look for the property as a real key on a real node. Four of the embed documents I probed contain the string &lt;code&gt;aggregateRating&lt;/code&gt; somewhere in their markup or scripts while publishing no such property. A substring test would have reported four ratings that do not exist. My harness was validated against a document that definitely has a parsed rating (detected) and one where the string appears only in an HTML comment and in body text (not detected, while the substring test said yes).&lt;/p&gt;

&lt;h2&gt;
  
  
  The controlled pair
&lt;/h2&gt;

&lt;p&gt;This is the whole finding in one table. Both URLs are generated by the same script, from the same template, and their own JSON-LD is identical in shape: one block containing a &lt;code&gt;BreadcrumbList&lt;/code&gt; and a &lt;code&gt;VideoGame&lt;/code&gt;, with no rating and no review anywhere in it. I verified that by parsing both pages, not by reading them.&lt;/p&gt;

&lt;p&gt;The only relevant difference between them is &lt;strong&gt;which document the static &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; loads&lt;/strong&gt;. One loads a partner page that publishes a JSON-LD graph. The other loads a partner page that publishes none.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Google Rich Results Test, 8 Aug 2026&lt;/th&gt;
&lt;th&gt;Page A — embed has a JSON-LD graph&lt;/th&gt;
&lt;th&gt;Page B — embed has no JSON-LD (control)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Crawled at&lt;/td&gt;
&lt;td&gt;21:40:29&lt;/td&gt;
&lt;td&gt;21:45:13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valid items detected&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Breadcrumbs&lt;/td&gt;
&lt;td&gt;1 valid item&lt;/td&gt;
&lt;td&gt;1 valid item&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Organisation&lt;/td&gt;
&lt;td&gt;1 valid item&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review snippets&lt;/td&gt;
&lt;td&gt;1 valid item&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Software apps&lt;/td&gt;
&lt;td&gt;1 valid item (1 non-critical issue)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rating in the page's own JSON-LD&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rating in the embed document&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ratingValue 4.6&lt;/code&gt;, &lt;code&gt;ratingCount 205&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;none — 0 JSON-LD blocks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Page A is &lt;a href="https://slowden.com/games/mystery-squishy-dumpling-blind-box/" rel="noopener noreferrer"&gt;Mystery Squishy Dumpling Blind Box&lt;/a&gt;, a partner game credited on the provider's own page to &lt;strong&gt;Universe 25&lt;/strong&gt;. Page B is &lt;a href="https://slowden.com/games/deadly-descent/" rel="noopener noreferrer"&gt;Deadly Descent&lt;/a&gt;. Both are third-party games playable on SlowDen; neither is a SlowDen Original, and this article makes no claim about either game's quality.&lt;/p&gt;

&lt;p&gt;Three of the four rich-result item types vanish when the embed changes. That is what a control is for: it rules out the explanation that our own template quietly grew a rating, and it rules out the tool inventing items.&lt;/p&gt;

&lt;p&gt;The detail that settles provenance is inside the Review snippet item. Its &lt;code&gt;@id&lt;/code&gt; is &lt;code&gt;https://playgama.com/game/mystery-squishy-dumpling-blind-box#Game&lt;/code&gt; — a URL on the provider's domain, reported by Google as a valid item on a &lt;code&gt;slowden.com&lt;/code&gt; URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually inside the embed
&lt;/h2&gt;

&lt;p&gt;I expected a rating. What the embed document publishes is an entire identity graph.&lt;/p&gt;

&lt;p&gt;Parsed from the frame's own URL, its single JSON-LD block contains an &lt;code&gt;Organization&lt;/code&gt;, a &lt;code&gt;WebSite&lt;/code&gt; and an &lt;code&gt;ItemPage&lt;/code&gt;, and nested inside the &lt;code&gt;ItemPage&lt;/code&gt; is the game itself. The game node is co-typed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"VideoGame"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WebApplication"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"Mystery Squishy Dumpling Blind Box"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"@id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="s2"&gt;"https://playgama.com/game/mystery-squishy-dumpling-blind-box#Game"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Person"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Universe 25"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"offers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Offer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"priceCurrency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"aggregateRating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AggregateRating"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                     &lt;/span&gt;&lt;span class="nl"&gt;"ratingValue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ratingCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;205&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                     &lt;/span&gt;&lt;span class="nl"&gt;"bestRating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"worstRating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at those three properties together, because that combination is not decorative. Google's &lt;a href="https://developers.google.com/search/docs/appearance/structured-data/software-app" rel="noopener noreferrer"&gt;Software App&lt;/a&gt; rich result is valid on &lt;code&gt;SoftwareApplication&lt;/code&gt; and its subtypes including &lt;code&gt;WebApplication&lt;/code&gt;, and its required properties are &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;offers.price&lt;/code&gt;, and &lt;code&gt;aggregateRating&lt;/code&gt; &lt;em&gt;or&lt;/em&gt; &lt;code&gt;review&lt;/code&gt;. The embed document carries all three. It is a complete, qualifying rich-result item.&lt;/p&gt;

&lt;p&gt;Which produces a result worth sitting with. There is &lt;strong&gt;no Google rich result for a game&lt;/strong&gt; — neither &lt;code&gt;VideoGame&lt;/code&gt; nor &lt;code&gt;Game&lt;/code&gt; appears in &lt;a href="https://developers.google.com/search/docs/appearance/structured-data/search-gallery" rel="noopener noreferrer"&gt;Google's list of supported features&lt;/a&gt;. The only one a game page can earn is Software App, and it requires a rating. A site with no rating system of its own is therefore locked out of it by construction. Our pages are locked out of it — and Google's own tool says one of them qualifies anyway, on numbers we did not collect.&lt;/p&gt;

&lt;p&gt;The enclosing graph goes further than the game. Alongside it the embed publishes the platform's own &lt;code&gt;WebSite&lt;/code&gt; node, a &lt;code&gt;MobileApplication&lt;/code&gt; node for the platform's Android app, and a &lt;code&gt;publisher&lt;/code&gt; &lt;code&gt;Organization&lt;/code&gt; carrying the company's legal name, its VAT and DUNS identifiers, a registered postal address, a support email, a named founder with job title, eight &lt;code&gt;sameAs&lt;/code&gt; social profiles and a platform marketing description. I am describing the field types rather than reprinting the values, because none of it is mine to republish — but all of it is what Google reads on our URL.&lt;/p&gt;

&lt;p&gt;That also explains the oddest line in the test result. The Software App item detected on our game page is not named after the game. It is &lt;strong&gt;named after the platform&lt;/strong&gt;: it is the provider's Android app listing, surfacing as a Software App rich-result item on a page about a browser game.&lt;/p&gt;

&lt;p&gt;One more thing worth noticing, and it closes a loop from &lt;a href="https://dev.to/liza_18827c15bdd387c4cd9c/200-game-pages-200-star-ratings-0-machine-readable-controls-3jem"&gt;last week's article about the game-metadata field nobody fills in&lt;/a&gt;. This graph is close to a model implementation — genre, device list, operating system, author, offer, publish date, platform list, a &lt;code&gt;PlayAction&lt;/code&gt;. It contains &lt;strong&gt;zero&lt;/strong&gt; schema.org accessibility properties. Not one. The field that tells a player whether their phone is enough is absent even here, in markup this careful. Google pays for the rating field. It pays nothing for the accessibility field. The incentive is visible in a single document.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much of a catalogue this touches
&lt;/h2&gt;

&lt;p&gt;Two pages prove a mechanism. The next question is scale, so I measured every page and every embed we ship. All figures are &lt;strong&gt;live&lt;/strong&gt;, fetched from the public site and the public embed URLs on 8 August 2026, and both sides came back complete — 200 of 200, zero fetch errors.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measured live, 8 Aug 2026&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Game pages returning HTTP 200&lt;/td&gt;
&lt;td&gt;200 of 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Game pages carrying a rating in their &lt;em&gt;own&lt;/em&gt; parsed JSON-LD&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Game pages shipping the partner iframe in &lt;em&gt;static HTML&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embed documents returning HTTP 200&lt;/td&gt;
&lt;td&gt;200 of 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embed documents serving a parsed &lt;code&gt;aggregateRating&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;190&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embed documents serving none&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The split between 190 and 10 is not random, and the reason is useful if you are auditing your own site. All 190 rated embeds are served from the platform's own domain. All 10 unrated ones are served from &lt;em&gt;standalone game domains&lt;/em&gt; the platform routes to instead — a different, much smaller document, roughly 32 KB against 99 KB, with &lt;strong&gt;zero&lt;/strong&gt; JSON-LD blocks in every single case. The same provider, two delivery paths, and only one of them hands you a graph.&lt;/p&gt;

&lt;p&gt;So "my provider publishes structured data" is not a per-provider fact. It is a per-URL fact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One number I am deliberately not publishing.&lt;/strong&gt; I have the rating values for all 190 and it would be easy to average them. I am not going to, because an average would read as a quality claim about a catalogue whose ratings we did not collect and cannot stand behind. What is fair to report is the &lt;em&gt;shape&lt;/em&gt;, because the shape is the point: the values run from &lt;strong&gt;3.9 to 4.8&lt;/strong&gt;, exactly &lt;strong&gt;one&lt;/strong&gt; of the 190 sits below 4.0, and &lt;strong&gt;none&lt;/strong&gt; sits below 3.5. An inherited rating is not a neutral signal. A page that inherits one inherits a flattering one.&lt;/p&gt;

&lt;p&gt;For clarity about what is what: all 200 games in that catalogue are third-party partner games, credited to their studios. SlowDen's own game sits outside that registry. Nothing in this article is a claim about who made any of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this a game-portal thing?
&lt;/h2&gt;

&lt;p&gt;That was my first assumption and it is wrong in both directions. I probed 14 public embed endpoints across six categories, parsing the JSON-LD of each. Eleven returned a document I could read.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Embed endpoint&lt;/th&gt;
&lt;th&gt;Kind&lt;/th&gt;
&lt;th&gt;Parsed JSON-LD&lt;/th&gt;
&lt;th&gt;Rating?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Game portal (partner player)&lt;/td&gt;
&lt;td&gt;game&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Organization&lt;/code&gt;, &lt;code&gt;WebSite&lt;/code&gt;, &lt;code&gt;ItemPage&lt;/code&gt; → &lt;code&gt;VideoGame&lt;/code&gt;+&lt;code&gt;WebApplication&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video player A&lt;/td&gt;
&lt;td&gt;video&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VideoObject&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live-video player&lt;/td&gt;
&lt;td&gt;video&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Organization&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Archive player&lt;/td&gt;
&lt;td&gt;video&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;BreadcrumbList&lt;/code&gt;, &lt;code&gt;ListItem&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video player B&lt;/td&gt;
&lt;td&gt;video&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Music player A&lt;/td&gt;
&lt;td&gt;audio&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Music player B&lt;/td&gt;
&lt;td&gt;audio&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Map embed&lt;/td&gt;
&lt;td&gt;map&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Social post embed&lt;/td&gt;
&lt;td&gt;social&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Game widget (indie marketplace)&lt;/td&gt;
&lt;td&gt;game&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Game embed (HTML5 supplier), 2 games&lt;/td&gt;
&lt;td&gt;game&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code sandbox embed&lt;/td&gt;
&lt;td&gt;code&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;not measured&lt;/em&gt; — 403 to an automated request&lt;/td&gt;
&lt;td&gt;unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Game portal (major)&lt;/td&gt;
&lt;td&gt;game&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;not measured&lt;/em&gt; — 401, no public embed endpoint&lt;/td&gt;
&lt;td&gt;unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Game embed (second HTML5 supplier)&lt;/td&gt;
&lt;td&gt;game&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;not measured&lt;/em&gt; — no public endpoint found&lt;/td&gt;
&lt;td&gt;unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things follow. Most embeds ship no structured data at all, so this is not a general emergency — and it is also not predictable from the category. Two video players in the same category differ from each other. Two game embeds in the same category differ from each other. The only way to know is to look at your own embed URL.&lt;/p&gt;

&lt;p&gt;A note on the three unmeasured rows, because getting this wrong is how false findings get published: &lt;strong&gt;a 401 or a 403 is not a zero.&lt;/strong&gt; One endpoint requires authentication and one blocks automated requests. Both may well serve structured data to a browser. They are recorded as unknown, not absent.&lt;/p&gt;

&lt;p&gt;And the four grep false positives all came from this group: a substring search for &lt;code&gt;aggregateRating&lt;/code&gt; returns true on two video players, a music player and one other — none of which publishes the property. If you audit with &lt;code&gt;grep&lt;/code&gt; you will find ratings that are not there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nobody did anything wrong, and it still crosses a guideline
&lt;/h2&gt;

&lt;p&gt;This is the part I want to be careful about, because the easy version of this story is an accusation and the true version is not one.&lt;/p&gt;

&lt;p&gt;The provider wrote correct, thorough markup on its own page and made that page embeddable, which is the entire point of an embed. We embedded it the ordinary way. Neither of us wrote a rating onto a SlowDen URL. And yet, read against Google's published guidance, the resulting page sits on the wrong side of two lines.&lt;/p&gt;

&lt;p&gt;From Google's &lt;a href="https://developers.google.com/search/docs/appearance/structured-data/sd-policies" rel="noopener noreferrer"&gt;general structured data guidelines&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Don't mark up content that is not visible to readers of the page."&lt;/p&gt;

&lt;p&gt;"Your structured data must be a true representation of the page content."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Our page's own text contains no rating: I checked, and there are zero occurrences of the rating value, the rating count, the words "rating", "rated" or "review", and zero star glyphs anywhere in the rendered text we serve.&lt;/p&gt;

&lt;p&gt;From Google's &lt;a href="https://developers.google.com/search/docs/appearance/structured-data/review-snippet" rel="noopener noreferrer"&gt;review snippet guidelines&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Don't aggregate reviews or ratings from other websites."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which is precisely what a load-time embed does on your behalf, silently, at whatever scale you embed at.&lt;/p&gt;

&lt;p&gt;And here is the closest thing to an acknowledgement in Google's own text that widget-delivered markup counts as being on the host page. It appears in the specific rule about businesses reviewing themselves, describing how such a review can arrive:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"…either directly in their structured data or &lt;strong&gt;through an embedded third-party widget&lt;/strong&gt;…"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Read that precisely.&lt;/strong&gt; The ineligibility rule it belongs to is about a local business or organisation hosting reviews of &lt;em&gt;itself&lt;/em&gt;, which is not our situation and probably not yours. I am not claiming that rule applies here. What the sentence establishes is narrower and more useful: &lt;strong&gt;Google treats structured data arriving via an embedded third-party widget as structured data on the host page.&lt;/strong&gt; That is the assumption my measurement rests on, and Google's documentation states it in passing rather than leaving it to inference.&lt;/p&gt;

&lt;p&gt;Set the search engine aside for a moment, though, because the trust problem does not depend on it. If a visitor ever sees stars next to your domain in a result, they will read those stars as yours. Ours are not. We collect no ratings, we have never published one, and we are not in a position to defend a number a partner computed on a different site from a different audience. That is a reason to care about this even if Google never renders it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we have not applied the obvious fix
&lt;/h2&gt;

&lt;p&gt;There is a one-line fix and we are not using it, and saying why is more useful than pretending the problem is hard.&lt;/p&gt;

&lt;p&gt;Our generator already has a flag that creates the game frame &lt;strong&gt;on click&lt;/strong&gt; instead of at page load. Flip it and the frame does not exist when a crawler renders the page, which would very likely end the attribution.&lt;/p&gt;

&lt;p&gt;It stays off for a reason that outranks a flattering rating: &lt;strong&gt;nobody has verified what the provider counts when the frame is created on click rather than at load.&lt;/strong&gt; Every embed URL carries a partner parameter that credits the play, and that parameter is how the arrangement works at all. Reasoning about our own markup cannot answer a question about someone else's counting. A rating we did not ask for is a smaller harm than a play that does not register, so the flag stays where it is until the counting question is answered by the provider rather than by us guessing.&lt;/p&gt;

&lt;p&gt;There was also a second, plainer reason it was turned off in the first place: a click-to-start poster in front of a player that already has its own start screen made it three clicks to reach a game.&lt;/p&gt;

&lt;p&gt;If you are in the same position, the honest options are the same three. &lt;strong&gt;Ask the provider&lt;/strong&gt; whether their markup is meant to travel with the embed — many providers would rather it did not. &lt;strong&gt;Create the frame after a click&lt;/strong&gt;, once you know what that does to whatever the provider measures. Or &lt;strong&gt;accept it deliberately&lt;/strong&gt; and write down the reason, which is what we have done, in a comment next to the iframe so the next person does not silently "fix" it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five minutes on your own site
&lt;/h2&gt;

&lt;p&gt;This costs almost nothing and I would be surprised if most sites with embeds have run it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write down what you think your page publishes.&lt;/strong&gt; Open your own template and list the structured-data types you wrote. Ours is two: a breadcrumb and a game.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the page through &lt;a href="https://search.google.com/test/rich-results" rel="noopener noreferrer"&gt;Google's Rich Results Test&lt;/a&gt;.&lt;/strong&gt; Compare its list of detected items to your list. Anything extra did not come from you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expand each unexpected item and read its &lt;code&gt;@id&lt;/code&gt; and &lt;code&gt;url&lt;/code&gt;.&lt;/strong&gt; A property pointing at a domain that is not yours tells you exactly where it came from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fetch your embed's URL on its own&lt;/strong&gt; and parse its JSON-LD. Parse it — do not search the text for property names, or you will find four ratings that do not exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test a page with a different embed as a control.&lt;/strong&gt; This is the step that turns a suspicion into a finding. If you have only one embed provider, compare against any page on your site that has no embed at all.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you ship an embeddable widget rather than host one, the same measurement points the other way and is worth ten minutes of your time: &lt;strong&gt;whatever your frame document publishes, your customers publish too.&lt;/strong&gt; A rating, an offer, a company identity, a founder's name. Structured data on an embeddable page is not the same object as structured data on a normal page, and most embeddable pages appear to have been built by copying the normal one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed, and what we did not
&lt;/h2&gt;

&lt;p&gt;Nothing about the site changed today as a result of this. That is deliberate, and it is the honest end of the story rather than a tidy one.&lt;/p&gt;

&lt;p&gt;What did change is that the decision is now written down where it cannot be lost: our build refuses, as a fatal error, to emit any rating property in any structured-data block, tested by deliberately poisoning a scratch copy of the generator to confirm the guard actually fires. That protects against the version of this problem we &lt;em&gt;can&lt;/em&gt; control — someone adding a rating because a rich result requires one. It does nothing about markup arriving in a frame, and pretending otherwise would be worse than the gap.&lt;/p&gt;

&lt;p&gt;The gap that remains, stated plainly: &lt;strong&gt;190 of our game pages are eligible, in Google's own tool, for rich results built from a rating we did not collect.&lt;/strong&gt; One of them has already been recorded as a valid review-snippet item by Search Console. We know why, we know the one lever that would change it, and we are not pulling that lever on an unverified assumption about someone else's counting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can structured data inside an iframe really be attributed to the host page?&lt;/strong&gt;&lt;br&gt;
We measured a case where it was. Two pages from the same generator, built from the same template on the same day, differ only in which document their static iframe points at. Google's Rich Results Test reported 4 valid items on the page whose embed carries a JSON-LD graph and 1 valid item on the page whose embed carries none, and the detected review-snippet item carried an &lt;code&gt;@id&lt;/code&gt; on the provider's domain. Google does not publish a rule stating that this happens, so treat it as a measured behaviour on one date rather than a documented guarantee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I check what my own embeds publish on my behalf?&lt;/strong&gt;&lt;br&gt;
Run your page through Google's Rich Results Test and compare the detected items against the structured data you actually wrote. Anything extra came from somewhere else. Then fetch your embed's URL directly and parse its JSON-LD. Do not use a text search for the property name: on four of the embed documents we probed, searching the HTML for &lt;code&gt;aggregateRating&lt;/code&gt; returned true while the parsed structured data contained no such property.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this a real problem or a curiosity?&lt;/strong&gt;&lt;br&gt;
Google's general structured data guidelines say not to mark up content that is not visible to readers of the page, and its review snippet guidelines say not to aggregate reviews or ratings from other websites. A host page can end up in both positions without writing a line of markup. Separately, and independently of any search engine, a rating a visitor reads as yours that you never collected is a trust problem you cannot defend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which kinds of embed carry structured data inside them?&lt;/strong&gt;&lt;br&gt;
Fewer than you would guess, and it is not predictable by category. Of 11 public embed endpoints we could read, 4 served parseable JSON-LD and only 1 served a rating. Two video players in the same category behaved differently from each other, and so did two game embeds. Two further endpoints refused an automated request, which is not the same as serving nothing, and are recorded as unknown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I stop embedding third-party content?&lt;/strong&gt;&lt;br&gt;
No. The embed is usually the product. The point is to know what it publishes in your name and to check rather than assume. If your provider ships structured data you cannot stand behind, the options are to raise it with them, to create the frame after a click, or to accept it deliberately with the reason recorded. Creating the frame on click can affect whatever the provider counts, so verify that before changing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the embed's canonical tag affect the host page too?&lt;/strong&gt;&lt;br&gt;
We did not measure that and are not claiming it. The embed document does declare its own canonical, &lt;code&gt;og:url&lt;/code&gt; and &lt;code&gt;og:title&lt;/code&gt; pointing at the provider's domain, and anything fetching the frame's URL can see them. What we measured is structured-data attribution, not canonicalisation. The rest is unverified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does a page with no rating system care about rich results at all?&lt;/strong&gt;&lt;br&gt;
Because the only Google rich result a game page can earn is Software App, and it requires an &lt;code&gt;aggregateRating&lt;/code&gt; or a &lt;code&gt;review&lt;/code&gt;. There is no rich result for a game as such. A site that collects no ratings is locked out by construction, and the only honest route in is to build a real rating system of its own. Copying a partner's number is not a shortcut, it is a false claim.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written on SlowDen, a place to put a browser game where people can find it and play it — no download for the player, no store page for the developer. Games there come from creators, partners and third-party sources, each credited to whoever made it. Our creative challenge round, Slow Cook, closes on 31 August 2026: you get a prompt, you build something around it, you submit it for consideration. Selected entries have a chance to be featured and selected winners may receive a shoutout on SlowDen's social accounts — that is the honest list, and there is nothing guaranteed beyond it. &lt;a href="https://slowden.com/submit/" rel="noopener noreferrer"&gt;Submit a browser game&lt;/a&gt; · &lt;a href="https://slowden.com/slow-cook/" rel="noopener noreferrer"&gt;See the Slow Cook challenges&lt;/a&gt; · &lt;a href="https://slowden.com/blog/" rel="noopener noreferrer"&gt;More articles&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>gamedev</category>
      <category>programming</category>
    </item>
    <item>
      <title>200 game pages, 200 star ratings, 0 machine-readable controls</title>
      <dc:creator>Liza Bhadana</dc:creator>
      <pubDate>Fri, 07 Aug 2026 10:29:29 +0000</pubDate>
      <link>https://dev.to/liza_18827c15bdd387c4cd9c/200-game-pages-200-star-ratings-0-machine-readable-controls-3jem</link>
      <guid>https://dev.to/liza_18827c15bdd387c4cd9c/200-game-pages-200-star-ratings-0-machine-readable-controls-3jem</guid>
      <description>&lt;p&gt;A player lands on your game's page. Before they commit any attention to it they want one thing answered: &lt;strong&gt;do I have what this needs?&lt;/strong&gt; A keyboard, a mouse, a thumb. On a phone that question is the entire decision.&lt;/p&gt;

&lt;p&gt;Now look at what the page actually gives them. A star rating. A rating count. A genre list. A developer name. A publish date. A play button.&lt;/p&gt;

&lt;p&gt;I went and counted. Across &lt;strong&gt;200 game pages on four different browser-game portals&lt;/strong&gt;, every single one publishes a star rating. &lt;strong&gt;Not one publishes the machine-readable field that says what you need in your hands.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That field is not missing from the standard. schema.org defines it, it is valid on every game page on the web without an extension, and it goes in the same JSON-LD block that already carries the star rating. It is missing from the &lt;em&gt;practice&lt;/em&gt;, and the reason turns out to be worth a developer's time: nobody upstream is ever asked for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;200 game pages, four portals. 0 carry any schema.org accessibility property. 200 carry &lt;code&gt;aggregateRating&lt;/code&gt;.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;schema.org's own usage stats: &lt;code&gt;aggregateRating&lt;/code&gt; is on &lt;strong&gt;1M–10M domains&lt;/strong&gt;. &lt;code&gt;accessibilityControl&lt;/code&gt; — the field for "what input fully drives this" — is on &lt;strong&gt;1K–10K&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The data isn't hidden, it was never collected. &lt;strong&gt;190 of 190&lt;/strong&gt; supplier pages have a "How to Play" section; only &lt;strong&gt;107&lt;/strong&gt; name an input device anywhere in it. Of the 38 games where we hold no control text at all, &lt;strong&gt;3&lt;/strong&gt; have an upstream section that names a device.&lt;/li&gt;
&lt;li&gt;The supplier's device flag is not a substitute. &lt;strong&gt;182 of 190&lt;/strong&gt; pages claim mobile support; for &lt;strong&gt;118 of those&lt;/strong&gt;, the supplier's own how-to-play text never mentions touch.&lt;/li&gt;
&lt;li&gt;One platform gets this right, and it is the one that asks the developer directly. itch.io has &lt;strong&gt;25 input methods&lt;/strong&gt; in its taxonomy and &lt;strong&gt;65,185&lt;/strong&gt; HTML5 games declaring a keyboard.&lt;/li&gt;
&lt;li&gt;We shipped a fix into our own build: &lt;strong&gt;96 of 200 game pages&lt;/strong&gt; now emit &lt;code&gt;accessibilityControl&lt;/code&gt;. The other 104 emit nothing, on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I measured, and what these numbers are not
&lt;/h2&gt;

&lt;p&gt;Every figure here is a count of &lt;strong&gt;published metadata&lt;/strong&gt; — what a page says about a game. None of it is a count of behaviour. &lt;strong&gt;No game in this article is described as broken, or as working.&lt;/strong&gt; Games here run inside a cross-origin frame drawing to a WebGL canvas the host page cannot see into; screen captures of that region come back solid black even while the game runs perfectly, and asking the host document how many canvases exist returns zero. That trap has cost us a full day before. "The page documents X" is the strongest claim this evidence supports, and it is the one I am making.&lt;/p&gt;

&lt;p&gt;Three guards, because a measurement can be perfectly real and the conclusion still wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The wrong-game guard.&lt;/strong&gt; Our internal slug for a game is not the supplier's slug — for 38 of 200 entries they differ — and requesting the supplier's page with &lt;em&gt;our&lt;/em&gt; slug returns &lt;strong&gt;HTTP 200 for a completely different game&lt;/strong&gt;. Every upstream slug here is derived from the game's own embed URL, and every fetched page is rejected unless the &lt;code&gt;name&lt;/code&gt; in its JSON-LD matches the registry title exactly. 190 of 190 passed; 0 were rejected. Fetching a real page under a deliberately wrong expected title was also tested, and the guard rejected it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The detector-fires guard.&lt;/strong&gt; A checker that never fires is not a checker. The property scanner was run against a document that definitely contains &lt;code&gt;accessibilityControl&lt;/code&gt; (schema.org's own page for it — detected) and against one that definitely does not (&lt;code&gt;example.com&lt;/code&gt; — not detected).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The negative control.&lt;/strong&gt; Two of them, and one of them changed a number in this article. On itch.io, &lt;code&gt;itch.io/games/zzz-fake-filter-xyzzy&lt;/code&gt; returns 404 — good. But &lt;code&gt;itch.io/games/html5/zzz-not-real-xyzzy&lt;/code&gt; returns &lt;strong&gt;HTTP 200 with the full unfiltered game count&lt;/strong&gt;. An unknown filter is silently ignored rather than rejected. So "the filter URL returns results" proves nothing there; only "the filter returns a count &lt;em&gt;different&lt;/em&gt; from the unfiltered total" does. Every itch figure below passes that stricter test.&lt;/p&gt;

&lt;p&gt;And one I got wrong and had to fix mid-run, recorded because the fix is the useful part. My first device detector looked for the literal strings &lt;code&gt;wasd&lt;/code&gt;, &lt;code&gt;arrow keys&lt;/code&gt;, &lt;code&gt;keyboard&lt;/code&gt; and &lt;code&gt;spacebar&lt;/code&gt;. It therefore scored these two control lists as naming no keyboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hazmob FPS: Online Shooter  —  "W, A, S, D — move | Space — jump | Shift — run | C — crouch"
Driver Club: Highway Racing —  "W or up arrow — accelerate | S or down arrow — brake"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are obviously keyboard games. I found it by hand-reading the eight games the aggregate had flagged as odd, not by trusting the total. The corrected detector ships with a fixture set of 24 strings — 16 that must be detected and 8 that must not — and refuses to print numbers if any of them fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field has existed the whole time
&lt;/h2&gt;

&lt;p&gt;schema.org's &lt;a href="https://schema.org/accessibilityControl" rel="noopener noreferrer"&gt;&lt;code&gt;accessibilityControl&lt;/code&gt;&lt;/a&gt; is defined as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Identifies input methods that are sufficient to fully control the described resource.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is a property of &lt;code&gt;CreativeWork&lt;/code&gt;. &lt;code&gt;VideoGame&lt;/code&gt; inherits from &lt;code&gt;Game&lt;/code&gt;, which inherits from &lt;code&gt;CreativeWork&lt;/code&gt;, so it is valid on &lt;a href="https://schema.org/VideoGame" rel="noopener noreferrer"&gt;every game page&lt;/a&gt; on the web with no extension, no vendor prefix and no negotiation.&lt;/p&gt;

&lt;p&gt;The values come from a &lt;a href="https://www.w3.org/community/reports/a11y-discov-vocab/CG-FINAL-vocabulary-20260326/" rel="noopener noreferrer"&gt;W3C community group vocabulary&lt;/a&gt; (current version dated 26 March 2026), and there are six:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fullKeyboardControl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Users can fully control the resource through keyboard input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fullMouseControl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;…through mouse input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fullTouchControl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;…through touch input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fullSwitchControl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;…through switch input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fullVoiceControl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;…through voice input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fullVideoControl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;…through video input&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of those six answer the question a player actually has. &lt;code&gt;fullKeyboardControl&lt;/code&gt; means &lt;em&gt;you will need a computer.&lt;/em&gt; &lt;code&gt;fullTouchControl&lt;/code&gt; means &lt;em&gt;your phone is enough.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now compare how much the game vocabulary is used. These are schema.org's own published usage figures, from Google's web index, as printed on each term's page:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;schema.org term&lt;/th&gt;
&lt;th&gt;Domains using it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aggregateRating&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1M – 10M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;genre&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100K – 1M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VideoGame&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10K – 100K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gamePlatform&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10K – 100K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;playMode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10K – 100K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;numberOfPlayers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10K – 100K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;accessibilityControl&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1K – 10K&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cheatCode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 1K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gameTip&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 1K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;quest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 1K&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ratings are published on somewhere between one hundred and one thousand times as many domains as the field for "can this person operate it". &lt;code&gt;accessibilityControl&lt;/code&gt; sits one band above cheat codes.&lt;/p&gt;

&lt;h2&gt;
  
  
  200 pages, 200 ratings, 0 accessibility properties
&lt;/h2&gt;

&lt;p&gt;I fetched every game page I could verify: 191 on Playgama (190 derived from our own catalogue's embed URLs, plus one more), 3 each on CrazyGames, Poki and Y8. Every page was confirmed to be a real game page before being counted, and every one was checked for all six accessibility properties plus &lt;code&gt;accessMode&lt;/code&gt; and &lt;code&gt;accessModeSufficient&lt;/code&gt; — searching the whole document, not just the JSON-LD, so microdata and RDFa would have been caught too.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Portal&lt;/th&gt;
&lt;th&gt;Pages checked&lt;/th&gt;
&lt;th&gt;With any accessibility property&lt;/th&gt;
&lt;th&gt;With &lt;code&gt;aggregateRating&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Playgama&lt;/td&gt;
&lt;td&gt;191&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;191&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CrazyGames&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Poki&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Y8&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three more portals returned &lt;strong&gt;unknown&lt;/strong&gt;, not zero, and they are listed as unknown: Newgrounds would not respond to the fetch at all, and Armor Games and GameDistribution render their game links client-side so no game URL could be discovered from the homepage HTML. I would rather report three gaps than pad a round number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data isn't hidden. It was never collected.
&lt;/h2&gt;

&lt;p&gt;The obvious next thought is that portals are dropping information the developer supplied. That is not what is happening, and this is the part that changes what a developer should do about it.&lt;/p&gt;

&lt;p&gt;Every one of the 190 supplier pages has a &lt;strong&gt;How to Play&lt;/strong&gt; section. All 190. That field is fully populated — somebody wrote prose for every single game.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only 107 of those 190 sections name an input device anywhere in them.&lt;/strong&gt; The other 83 describe the goal, the modes, the progression and the vibe, and never say what you press.&lt;/p&gt;

&lt;p&gt;It gets sharper. Our catalogue holds no control text at all for 39 of 200 games. That has sat on our backlog as a data-entry job — go and fetch the missing controls. So I went and fetched them: &lt;strong&gt;38 of those 39 have a live upstream page, all 38 have a How to Play section, and 3 of the 38 name an input device in it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is nothing to fetch. The gap is upstream, and no amount of effort on our side closes it. That reframes a backlog item as a supplier problem, which is a cheaper thing to know than to discover slowly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The device flag is not a substitute, and it is only safe in one direction
&lt;/h2&gt;

&lt;p&gt;Every supplier page carries &lt;code&gt;availableOnDevice&lt;/code&gt;, and 182 of the 190 say &lt;code&gt;Desktop, Mobile, Tablet&lt;/code&gt;. If that were trustworthy the whole problem would be solved.&lt;/p&gt;

&lt;p&gt;It is not trustworthy in the affirmative. &lt;strong&gt;For 118 of those 182 games, the supplier's own How to Play section never mentions touch, tapping, swiping or an on-screen control.&lt;/strong&gt; The same page asserts phone support and then explains the game entirely in keys. One of the two is wrong and the page gives you no way to tell which.&lt;/p&gt;

&lt;p&gt;The negative direction held up perfectly, and that is genuinely useful:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;availableOnDevice&lt;/code&gt; says&lt;/th&gt;
&lt;th&gt;Pages&lt;/th&gt;
&lt;th&gt;Documents touch&lt;/th&gt;
&lt;th&gt;Has controls, names no touch&lt;/th&gt;
&lt;th&gt;No control text at all&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Desktop, Mobile, Tablet&lt;/td&gt;
&lt;td&gt;182&lt;/td&gt;
&lt;td&gt;71&lt;/td&gt;
&lt;td&gt;74&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Desktop only&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eight games are flagged desktop-only, and not one of them documents a touch control. &lt;strong&gt;Trust the "no". Never trust the "yes".&lt;/strong&gt; That is the rule we use, and 190 pages is the largest sample we have tested it on.&lt;/p&gt;

&lt;p&gt;Please also note what is &lt;em&gt;not&lt;/em&gt; in this article: the star ratings. They are right there on every page, they would make the catalogue look busier, and copying a supplier's rating into your own markup is asserting that those reviews were left on your site. They were not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our own blind spot, which is worse in one specific way
&lt;/h2&gt;

&lt;p&gt;None of the above lets us off. Here is our own catalogue, measured the same day:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;200 entries. 161 have control text. 39 have none.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Of the 161, &lt;strong&gt;39 more have control text that never names a device&lt;/strong&gt; — flavour, objectives, scoring.&lt;/li&gt;
&lt;li&gt;So &lt;strong&gt;78 of 200 — 39% — tell a player nothing about what to hold&lt;/strong&gt;, either way.&lt;/li&gt;
&lt;li&gt;Where a device is named: keyboard 75, mouse 64, touch 74, &lt;strong&gt;gamepad 0&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then the tags, which is the part I did not expect. The catalogue carries &lt;strong&gt;182 distinct tags&lt;/strong&gt;. Exactly two of them name an input device: &lt;code&gt;mouse&lt;/code&gt; (19 games) and &lt;code&gt;point and click&lt;/code&gt; (7 games). (A third, &lt;code&gt;arrows&lt;/code&gt;, matched the detector and turned out to be about the puzzle pieces in Arrow Exit Puzzle. Hand-checked and discarded.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no tag for a keyboard. There is no tag for touch. There is no tag for a gamepad.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tags are what filters, related-game rails and internal search are built from. A vocabulary of 182 terms that can say &lt;code&gt;beauty salon&lt;/code&gt;, &lt;code&gt;backgammon&lt;/code&gt; and &lt;code&gt;horde survival&lt;/code&gt;, and cannot say &lt;code&gt;keyboard&lt;/code&gt;, is a vocabulary in which "show me games I can play on this phone" is not an expressible query. Not hard — &lt;em&gt;impossible&lt;/em&gt;, until someone adds the word.&lt;/p&gt;

&lt;h2&gt;
  
  
  One platform does collect it, and the reason is not technical
&lt;/h2&gt;

&lt;p&gt;itch.io asks developers to tick their game's input methods when they publish it. Not a free-text box: a list of &lt;strong&gt;25 input methods&lt;/strong&gt;, from Keyboard and Touchscreen down through Dance pad, Eye tracker, Gyroscope and Stylus. Every one is a browsable filter.&lt;/p&gt;

&lt;p&gt;Measured today, browsable and countable by anyone:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;itch.io, HTML5 games&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;All HTML5 games&lt;/td&gt;
&lt;td&gt;710,347&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Declaring &lt;strong&gt;Keyboard&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;65,185&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Declaring &lt;strong&gt;Mouse&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;54,464&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Declaring &lt;strong&gt;Touchscreen&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;17,959&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Declaring &lt;strong&gt;Gamepad (any)&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;8,680&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Declaring &lt;strong&gt;Smartphone&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;6,491&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Be honest about what that shows. 65,185 out of 710,347 is about &lt;strong&gt;9%&lt;/strong&gt;. Roughly nine in ten HTML5 games on itch.io still declare nothing about a keyboard, and touchscreen is declared on &lt;strong&gt;2.5%&lt;/strong&gt;. The field existing is not sufficient.&lt;/p&gt;

&lt;p&gt;But 65,185 is not zero, and zero is what every other portal in this study returned. The difference is not technical — schema.org's field is free and universally valid, and itch's field is custom and non-standard. The difference is that &lt;strong&gt;one of them asks the person who knows.&lt;/strong&gt; Nobody has to reverse-engineer the input method of a game whose developer ticked "Touchscreen" at upload time.&lt;/p&gt;

&lt;p&gt;Three itch.io pages were sampled at random for this study alongside the other portals. None carried a schema.org accessibility property either — itch's input data lives in its own database and renders as a table row, not as markup a search engine can read. &lt;strong&gt;One of the three had an Inputs row filled in&lt;/strong&gt; (&lt;code&gt;Inputs: Keyboard&lt;/code&gt;). That is one more than the 200 pages on the other four portals managed between them, and it is also why the headline figure in this article says 200 rather than 203: I did not want a counter-example quietly padding a zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we shipped today
&lt;/h2&gt;

&lt;p&gt;Writing this without changing anything would have been cheap, so the generator that builds every SlowDen game page now emits &lt;code&gt;accessibilityControl&lt;/code&gt;. &lt;strong&gt;96 of 200 game pages carry it: 70 &lt;code&gt;fullTouchControl&lt;/code&gt; and 26 &lt;code&gt;fullKeyboardControl&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The other 104 emit nothing, and the rule for that is the interesting part.&lt;/p&gt;

&lt;p&gt;The approved values assert that &lt;strong&gt;one&lt;/strong&gt; input fully drives the game. A control list that names WASD &lt;em&gt;and&lt;/em&gt; a mouse supports neither claim on its own, so those games get nothing. What we do emit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fullKeyboardControl&lt;/code&gt;&lt;/strong&gt; — the 26 games on a hand-verified list where every documented control is a keyboard key and no touch alternative appears anywhere in the text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fullTouchControl&lt;/code&gt;&lt;/strong&gt; — 70 games whose control list names a real touch &lt;em&gt;input&lt;/em&gt; (tapping, swiping, dragging, an on-screen joystick) and which either names no keyboard at all, or also names a mobile device, which is the shape of a list documenting two complete schemes, one per device. All 26 borderline cases in that second group were read by hand before this shipped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mouse is deliberately &lt;strong&gt;not&lt;/strong&gt; emitted, even for the games that look mouse-only. Keyboard false negatives are real — I produced two of them this morning — so "mouse only" is a verdict resting on the least reliable of the three detectors. Skipping it costs a handful of games and removes the one claim I could not defend.&lt;/p&gt;

&lt;p&gt;A negation guard is in there too. It matches zero entries today. It exists because the next catalogue import might contain "not available on mobile", a sentence containing every word the accept rule looks for, and shipping &lt;code&gt;fullTouchControl&lt;/code&gt; off the back of it would be a false accessibility claim — the one kind of error in that file that hurts a person rather than a ranking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why an incomplete answer is safe here, when an incomplete device flag is not.&lt;/strong&gt; &lt;code&gt;accessibilityControl&lt;/code&gt; is a &lt;em&gt;positive&lt;/em&gt; claim. Saying a game is fully keyboard-controllable does not say it lacks touch. Missing data degrades to silence rather than to a lie. A boolean &lt;code&gt;mobileReady&lt;/code&gt; has no such property — it is forced to answer, so it answers wrongly 118 times out of 182. That asymmetry is the whole reason a partial answer can be published at all.&lt;/p&gt;

&lt;p&gt;We know this from experience, not theory. Our own entry for &lt;strong&gt;Basket Random&lt;/strong&gt; (RHM Interactive) lists only keys — but the publisher's own public page documents &lt;em&gt;Virtual Buttons: Jump and Throw (Mobile)&lt;/em&gt;, a line our copy dropped. &lt;code&gt;fullKeyboardControl&lt;/code&gt; on that game is still true. A &lt;code&gt;mobileReady: false&lt;/code&gt; would have been false. We published a correction for that one two days ago and it is why the rule is shaped this way.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you make browser games, five things
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Name the device in the first line of your controls.&lt;/strong&gt; Not "Move with WASD" — "&lt;strong&gt;Keyboard:&lt;/strong&gt; move with WASD". Portals copy your prose verbatim into their How to Play field, and prose is the only channel that reliably survives the trip. 83 of 190 games in this study lost the information right here, at the source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Put &lt;code&gt;accessibilityControl&lt;/code&gt; on your own game page.&lt;/strong&gt; Four lines, and yours becomes one of the few game pages on the web that answers the question in a form a machine can read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://schema.org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VideoGame"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Your Game"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"accessibilityControl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"fullKeyboardControl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fullMouseControl"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only claim an input that genuinely drives the whole game. If a player needs both hands on two devices, say both — and if neither alone is enough, the honest move is the same one we made: say nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Tick the boxes on the platforms that have them.&lt;/strong&gt; itch.io's Inputs field takes about four seconds and puts your game into a filter that 90% of HTML5 games on the site are absent from. That is a discovery surface with almost no competition on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Write it into your submission text as well as your metadata form.&lt;/strong&gt; Structured fields do not always propagate between platforms — the partner export our own catalogue is built from carries no developer field at all, which is why studio credits had to be read out of each game's public page one at a time. Prose propagates. Redundancy is the point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Test it the way a stranger will.&lt;/strong&gt; Unplug the keyboard and try to reach the main menu. Open it on a phone and see whether anything responds to a tap. Whatever you learn is more than any portal currently knows about your game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Everything here counts documentation, not behaviour. No game was tested by playing it, and none is described as working or broken.&lt;/li&gt;
&lt;li&gt;200 pages is a real sample, but three of the four portals contributed three pages each. Playgama at 191 pages is the deep result; CrazyGames, Poki, Y8 and itch.io are spot checks, and they are consistent with it rather than proof on their own.&lt;/li&gt;
&lt;li&gt;Three portals — Newgrounds, Armor Games, GameDistribution — are &lt;strong&gt;unknown&lt;/strong&gt;, not zero.&lt;/li&gt;
&lt;li&gt;The itch.io counts are a live snapshot taken on 7 August 2026 and moved by six games while I was measuring them.&lt;/li&gt;
&lt;li&gt;The 96 pages we now annotate are &lt;strong&gt;staged in our build, not yet live&lt;/strong&gt;. slowden.com is deployed by hand and this ships on the next one. The claim "96 pages emit it" is verified against the generated files; the live site at the time of writing carries 0.&lt;/li&gt;
&lt;li&gt;No keyword-demand tool is connected here, so nothing in this article says anything about search volume for any of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;If you have a browser game finished, or nearly, &lt;a href="https://slowden.com/submit/" rel="noopener noreferrer"&gt;submit it to SlowDen&lt;/a&gt; — it takes a form and a link, and a person reads every one. If you would rather have a constraint to build against, the current &lt;a href="https://slowden.com/slow-cook/" rel="noopener noreferrer"&gt;Slow Cook&lt;/a&gt; round closes on &lt;strong&gt;31 August 2026&lt;/strong&gt;: pick a challenge, build something small around it, and send it in. Selected entries may get a shoutout on SlowDen's social accounts and a chance to be featured. Nothing is guaranteed, and that is the honest version.&lt;/p&gt;

&lt;p&gt;And when you write the page for it, tell people what to hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://slowden.com/blog/why-your-browser-game-is-unplayable-on-a-phone/" rel="noopener noreferrer"&gt;Why your browser game is unplayable on a phone&lt;/a&gt; — the audit this one grew out of: 68 of 155 control lists that never mention touch.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://slowden.com/blog/two-players-one-keyboard/" rel="noopener noreferrer"&gt;Two players, one keyboard&lt;/a&gt; — the multiplayer mode a phone cannot run, and where the second player's keys go missing.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://slowden.com/blog/why-players-leave-before-your-game-starts/" rel="noopener noreferrer"&gt;Why players leave before your game starts&lt;/a&gt; — what happens in the seconds before any of this matters.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://slowden.com/blog/why-your-game-breaks-inside-an-iframe/" rel="noopener noreferrer"&gt;Why your game breaks inside an iframe&lt;/a&gt; — the other thing the page does to your game without telling you.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://slowden.com/blog/how-to-submit-your-browser-game-to-slowden/" rel="noopener noreferrer"&gt;How to submit your browser game to SlowDen&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Games named in this article are Playgama partner titles listed on SlowDen and remain the work of their studios: Moto X3M by MadPuffers, Snake Arena by John Hany, Hedgies by RedSpell, Basket Random by RHM Interactive, Hazmob FPS: Online Shooter by Hazmob, Driver Club: Highway Racing by efecanbuzluk90. SlowDen did not make them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>gamedev</category>
      <category>a11y</category>
      <category>seo</category>
    </item>
    <item>
      <title>Two players, one keyboard: the multiplayer a phone cannot run</title>
      <dc:creator>Liza Bhadana</dc:creator>
      <pubDate>Wed, 05 Aug 2026 15:43:34 +0000</pubDate>
      <link>https://dev.to/liza_18827c15bdd387c4cd9c/two-players-one-keyboard-the-multiplayer-a-phone-cannot-run-2ehb</link>
      <guid>https://dev.to/liza_18827c15bdd387c4cd9c/two-players-one-keyboard-the-multiplayer-a-phone-cannot-run-2ehb</guid>
      <description>&lt;p&gt;Somewhere in your game there is a line that reads &lt;em&gt;Player 1: WASD. Player 2: arrow keys.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It is the cheapest multiplayer anyone has ever shipped — no server, no netcode, no accounts, no matchmaking, no lobby that sits empty at 3am. One running copy of the game, one input device, two people.&lt;/p&gt;

&lt;p&gt;It is also the only common game mode that a phone cannot run at all, because there is no keyboard to cut in half.&lt;/p&gt;

&lt;p&gt;This article is about what that trade actually costs, measured across a real 200-game catalogue, and about a smaller, more annoying discovery: &lt;strong&gt;the part of your control text that explains the second player is the part most likely to never reach a player.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The short version:&lt;/strong&gt; 13 of 200 games in our catalogue document two people on one device. 9 of those hand out two separate key sets with no touch alternative in our copy of the text. Exactly &lt;strong&gt;1 of the 9&lt;/strong&gt; is filed under "2 Player". Nine control lists in the catalogue are cut off mid-sentence between 250 and 259 characters, and what the cut removes is the mobile section or the second player's keys. And when we checked one of the nine against its publisher's own public page, the mobile controls were there all along — our copy had dropped them. That last one is a correction to our own article from this morning.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What we measured, and what these numbers are not
&lt;/h2&gt;

&lt;p&gt;Every number below is a count of &lt;strong&gt;documentation&lt;/strong&gt;: the control text a developer or publisher wrote about their own game, as it sits in SlowDen's game registry, plus what the publisher's own public page says. It is not a count of behaviour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No game in this article is described as broken, or as working.&lt;/strong&gt; We cannot tell you that from where we are sitting, and it is worth saying why, because the reason is a trap other people will hit too. These games run inside a cross-origin frame, drawing to a WebGL canvas we cannot see into. Screen captures of that region come back solid black even when the game is running perfectly, and a script asking the host page how many canvases exist gets the answer zero, because the canvas is not in the host document. We learned that the expensive way. So when this article says a game "is documented as" something, that phrasing is doing real work, and it is the strongest claim the evidence supports.&lt;/p&gt;

&lt;p&gt;Two other honest limits. The registry holds &lt;strong&gt;200 games in our source&lt;/strong&gt;, of which &lt;strong&gt;120 are on the live site today&lt;/strong&gt; — our deploys are a manual step and the source runs ahead. Every game named below is labelled live or staged. And this is one catalogue of partner games, not a survey of the web.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: 13 games document two people on one device
&lt;/h2&gt;

&lt;p&gt;Of the 200 entries, &lt;strong&gt;155 carry a documented control list&lt;/strong&gt; and 45 carry none at all. Reading all 155, sixteen entries describe a second player. They split three ways:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;What the control text says&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A. Two players, one keyboard&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Two separate key sets, and no touch or mobile input mentioned anywhere in our copy of the text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;B. Two players, touch documented&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Two key sets &lt;em&gt;and&lt;/em&gt; an on-screen or mobile path spelled out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C. A second player, no keys&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"with a friend", "for one or two players" — and no input list at all&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Group A is the interesting one. Here it is in full, with each game credited to its studio. All are Playgama partner titles; none was made by SlowDen.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Game&lt;/th&gt;
&lt;th&gt;Studio&lt;/th&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;On slowden.com&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Basket Random&lt;/td&gt;
&lt;td&gt;RHM Interactive&lt;/td&gt;
&lt;td&gt;Sports&lt;/td&gt;
&lt;td&gt;live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Soccer Random&lt;/td&gt;
&lt;td&gt;RHM Interactive&lt;/td&gt;
&lt;td&gt;Sports&lt;/td&gt;
&lt;td&gt;live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rolling Balls Sea Race&lt;/td&gt;
&lt;td&gt;gameVgames&lt;/td&gt;
&lt;td&gt;2 Player&lt;/td&gt;
&lt;td&gt;live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stickman Kombat 2D&lt;/td&gt;
&lt;td&gt;Ernar (Tempo Play)&lt;/td&gt;
&lt;td&gt;Funny&lt;/td&gt;
&lt;td&gt;live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kick Lucky Blocks Online&lt;/td&gt;
&lt;td&gt;Cursora Labs&lt;/td&gt;
&lt;td&gt;Block &amp;amp; Sandbox&lt;/td&gt;
&lt;td&gt;live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lucky Brainrot Blocks Online&lt;/td&gt;
&lt;td&gt;Cursora Labs&lt;/td&gt;
&lt;td&gt;Block &amp;amp; Sandbox&lt;/td&gt;
&lt;td&gt;staged, not live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Escape School Duel&lt;/td&gt;
&lt;td&gt;gameVgames&lt;/td&gt;
&lt;td&gt;Block &amp;amp; Sandbox&lt;/td&gt;
&lt;td&gt;staged, not live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catch a Fish Obby&lt;/td&gt;
&lt;td&gt;gameVgames&lt;/td&gt;
&lt;td&gt;Block &amp;amp; Sandbox&lt;/td&gt;
&lt;td&gt;staged, not live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GT Cars Mega Ramps&lt;/td&gt;
&lt;td&gt;gameVgames&lt;/td&gt;
&lt;td&gt;Racing&lt;/td&gt;
&lt;td&gt;staged, not live&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five are live; the other four are in our source and not on the site yet, so they are named rather than linked to a page that would return a 404.&lt;/p&gt;

&lt;p&gt;There is close to a standard here. Five of the nine document &lt;strong&gt;WASD for one player and the arrow keys for the other&lt;/strong&gt; — a convention old enough to predate the browser as a games platform, and one that survives because it is the only way to give two people non-overlapping keys on a keyboard neither of them chose. Two more take the opposite route and reduce each player to a &lt;em&gt;single&lt;/em&gt; key. That detail matters later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: local co-op is not in the "2 Player" category
&lt;/h2&gt;

&lt;p&gt;If you were a player looking for something to play with the person next to you, you would open the 2 Player category. Here is what is actually in it.&lt;/p&gt;

&lt;p&gt;On the live site the category holds &lt;strong&gt;8 games&lt;/strong&gt;, and &lt;strong&gt;6 of the 8&lt;/strong&gt; are chess, checkers, backgammon or sudoku — turn-based board games, where "two player" means taking turns with one mouse or one finger, and where the second player is often the computer or an online opponent. &lt;strong&gt;4 of the 8 carry no documented controls at all.&lt;/strong&gt; Only Rolling Balls Sea Race is a simultaneous shared-keyboard game.&lt;/p&gt;

&lt;p&gt;Meanwhile the nine games that genuinely need two people pressing keys at the same moment are scattered: &lt;strong&gt;4 in block-and-sandbox, 2 in Sports, 1 in Racing, 1 in Funny, and 1 in 2 Player.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This is not anyone's mistake.&lt;/strong&gt; A category describes what a game is &lt;em&gt;about&lt;/em&gt;. Basket Random is about basketball; it is filed under Sports, correctly. The problem is that "how many people can play this, on what device" is a completely different axis from genre, and almost every browser-game portal — ours included — only has the genre one. A player who wants couch co-op has no way to ask for it, and a developer who built couch co-op has no way to say so.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Finding 3: the second player's keys are the first thing to get cut
&lt;/h2&gt;

&lt;p&gt;While reading all 155 control lists, nine of them stopped mid-sentence with an ellipsis. Their lengths: &lt;strong&gt;250, 253, 253, 254, 254, 256, 257, 258 and 259 characters.&lt;/strong&gt; The longest control list in the catalogue that is &lt;em&gt;not&lt;/em&gt; cut runs to 507 characters, so this is not a site-wide limit — it is something that happened to those nine entries on their way in.&lt;/p&gt;

&lt;p&gt;What the cut removes is never random, because text has an order. Developers write the desktop keys first, then the second player, then the mobile section. So the truncation lands on exactly the two things this article is about:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the ellipsis ate&lt;/th&gt;
&lt;th&gt;Entries&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The second player's key list&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The mobile / touch section&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three concrete examples, quoted from our own registry exactly as they end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stickman-kombat-2d   … Player 2: IJKL + : (colon) (attack), " (quote)…

gt-cars-mega-ramps   … Player2 Arrow keys = move  N = NOS  L = reset car position  U =…

animal-evolution-simulator
                     … Mouse wheel - zoom in/out of the camera  Smartphone controls: Left…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third one is the clearest. A player on a phone reaches the words &lt;em&gt;"Smartphone controls:"&lt;/em&gt; and then the sentence stops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So we went and looked at where the text came from.&lt;/strong&gt; The publisher's own public page for that game carries the whole thing, with no ellipsis anywhere: a Mobile section listing a left joystick to move, a jump button, an attack button, a swipe on the left side of the screen for the camera, and taps for buttons. The information was never missing. It exists, it is public, and our copy of it stops one word into the sentence that a phone player needs. The same is true of GT Cars Mega Ramps, whose publisher page carries a complete control description and a plain statement that its two-player mode is split-screen on one keyboard.&lt;/p&gt;

&lt;p&gt;We have not seen the data payload in between, so we are not going to name a mechanism we did not observe. What is measured is this: &lt;strong&gt;the full text exists upstream, and the version we show is incomplete.&lt;/strong&gt; That is our defect to fix, and the fix is on our list rather than in this paragraph pretending to be already done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: a correction to our own article from this morning
&lt;/h2&gt;

&lt;p&gt;Earlier today we published &lt;a href="https://dev.to/liza_18827c15bdd387c4cd9c/why-your-browser-game-is-unplayable-on-a-phone-7hi"&gt;an audit of touch support&lt;/a&gt; across this same catalogue, and it put these nine games in a group described as documenting no touch input. Tonight we checked that claim against a source we had not used: each game's own public page on the publisher's site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One of the nine does document touch, and we were the ones losing it.&lt;/strong&gt; Basket Random's publisher page lists three controls, not two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;W: Jump and Throw (Player 1)
Up Arrow: Jump and Throw (Player 2)
Virtual Buttons: Jump and Throw (Mobile)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our registry entry for the same game carries the first two and not the third. Nothing was truncated here — the text is short and ends cleanly. It is simply a shorter rewrite that dropped the mobile line.&lt;/p&gt;

&lt;p&gt;We checked the remaining eight the same way, reading the controls section of each publisher page by hand. &lt;strong&gt;Eight of the nine document no touch path upstream either&lt;/strong&gt;, so the finding holds for those, and the one that does not has been corrected here and in the earlier article's record. Notably Soccer Random — the same studio, the same one-key-per-player design as Basket Random — carries no mobile line on its publisher page at all. We are not going to guess why two sibling games differ.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why print this rather than quietly edit a number.&lt;/strong&gt; The correction changes 9 to 8 on one row of one table. It is small. But a reader deciding whether to trust the rest of the measurements has no way to check them, and the only evidence we can offer is how we behave when we find one of our own numbers wrong within a day of publishing it. So: found, checked against a second source, corrected in public, cause named.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Finding 5: how much room two players would actually get
&lt;/h2&gt;

&lt;p&gt;Say you decided to port a shared-keyboard game to touch. How much screen is there to work with? We measured the embed box on a live SlowDen game page — &lt;code&gt;/games/basket-random/&lt;/code&gt;, one of the nine — at five viewport widths, using same-origin frames.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Viewport&lt;/th&gt;
&lt;th&gt;Game box&lt;/th&gt;
&lt;th&gt;Share of viewport area&lt;/th&gt;
&lt;th&gt;Split side by side&lt;/th&gt;
&lt;th&gt;Split top and bottom&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;360 × 740&lt;/td&gt;
&lt;td&gt;301 × 188&lt;/td&gt;
&lt;td&gt;21.2%&lt;/td&gt;
&lt;td&gt;150 × 188 each&lt;/td&gt;
&lt;td&gt;301 × 94 each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;390 × 844&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;331 × 207&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20.8%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;166 × 207 each&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;331 × 104 each&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;412 × 915&lt;/td&gt;
&lt;td&gt;353 × 221&lt;/td&gt;
&lt;td&gt;20.6%&lt;/td&gt;
&lt;td&gt;176 × 221 each&lt;/td&gt;
&lt;td&gt;353 × 110 each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;430 × 932&lt;/td&gt;
&lt;td&gt;371 × 232&lt;/td&gt;
&lt;td&gt;21.5%&lt;/td&gt;
&lt;td&gt;186 × 232 each&lt;/td&gt;
&lt;td&gt;371 × 116 each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;768 × 1024&lt;/td&gt;
&lt;td&gt;709 × 443&lt;/td&gt;
&lt;td&gt;39.9%&lt;/td&gt;
&lt;td&gt;354 × 443 each&lt;/td&gt;
&lt;td&gt;709 × 222 each&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The box holds at roughly a fifth of the screen's area across every phone width and roughly doubles on a tablet, which is what you would expect from a fixed aspect ratio inside a fixed page padding. We measured a different game page on a different run last time and got the same numbers to within a couple of pixels, which is how you know it is the template and not the game.&lt;/p&gt;

&lt;p&gt;Now the useful part. &lt;a href="https://www.w3.org/TR/WCAG22/#target-size-minimum" rel="noopener noreferrer"&gt;WCAG 2.2 Success Criterion 2.5.8&lt;/a&gt;, Level AA, asks for pointer targets of &lt;strong&gt;at least 24 by 24 CSS pixels&lt;/strong&gt;. A 166 by 207 half is nearly seven times that in each direction. So the question is never "is there room for a button". It is &lt;strong&gt;how many buttons each player needs&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One input per player&lt;/strong&gt; — Basket Random and Soccer Random both give each player a single key that jumps, blocks and shoots. Each player's half becomes one enormous tap zone. This is a small change, not a rewrite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two or three inputs per player&lt;/strong&gt; — still workable in a 166 by 207 half, if you are willing to design rather than decorate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move, jump, sprint, interact, camera, plus a shop and an inventory&lt;/strong&gt; — which is what four of the nine actually document. Two of those side by side on a phone is not a layout problem you can solve with a smaller button.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern worth noticing: &lt;strong&gt;the games that would be easiest to port are the ones already closest to it.&lt;/strong&gt; A one-button-per-player design is not a limitation to apologise for. It is the thing that makes two people on one phone a real possibility instead of a joke.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then there is what our own page says
&lt;/h2&gt;

&lt;p&gt;Here is the live page for Basket Random today, quoted exactly. It opens with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Play Basket Random free online on SlowDen — no download, no sign-up.
Press play and enjoy this Sports browser game instantly.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, under "How to play":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2. Click or tap inside the game window to start.
3. Follow the in-game prompts — controls are listed below.

Controls
• Player 1: W   Player 2: UP ARROW
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the page tells a visitor to tap, and then tells them the game needs two people at a keyboard. Across the five live pages in group A, the word "touch" and the words "swipe", "joystick" and "on-screen" appear &lt;strong&gt;zero times&lt;/strong&gt;, on any of them. The word "instantly" appears four times in each page's HTML.&lt;/p&gt;

&lt;p&gt;That is our template, on our site, and it is the least flattering paragraph in this article, which is the main reason it is here. The replacement is written and sitting in our source, unshipped, because our deploys are manual and the site is frozen this month. It reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;⌨ Two players, one keyboard

Built for a keyboard — and for two people at one. Basket Random is
documented as two players sharing a single keyboard, so it is made for
a computer rather than one phone. No touch alternative is listed.
That is what its control list says, not a test result.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We are not claiming credit for a fix a visitor cannot see yet. As of the time this was published, the live page still reads the way it is quoted above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six things worth doing
&lt;/h2&gt;

&lt;p&gt;In rough order of how much they return for the effort.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the device answer first in your controls text.&lt;/strong&gt; Not last. Every downstream system that reformats, shortens or truncates your description cuts from the end, and we have nine measured examples where the end was the mobile section or the second player. A first line reading &lt;em&gt;"Two players, one keyboard — desktop only"&lt;/em&gt; survives everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If your game has one action per player, ship touch.&lt;/strong&gt; You are one pair of tap zones away. Split the canvas down the middle, treat a tap on either half as that player's key, and leave the keyboard bindings in place for desktop. It is the single cheapest way to turn a desktop-only game into one that works on the device half your visitors are holding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say when two-player is desktop-only, on the page, in words.&lt;/strong&gt; One game in our catalogue does this and it is the best-documented entry we found: &lt;em&gt;"On Mobile Devices: Only 1 Player mode is enabled on mobile devices. Tap left and right on the screen to move."&lt;/em&gt; (Snow Rush 3D, by gameVgames — in our source, not yet on the live site.) A player who reads that is never disappointed. Compare it to a page that just says "tap to start".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make single-player complete on touch, and let co-op be the extra.&lt;/strong&gt; This is the version of the trade that costs you nothing. The mistake is not building shared-keyboard co-op — it is building it as the only way in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document desktop and mobile as two separate lists, not one blended paragraph.&lt;/strong&gt; The clearest entry in our whole catalogue does exactly that: Master Chess (CodeThisLab) lists &lt;em&gt;click a piece / click a square&lt;/em&gt; for desktop and &lt;em&gt;tap a piece / tap a square&lt;/em&gt; for mobile, as four separate lines. It takes thirty seconds to write and it answers the only question a phone player has.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not rely on a category to communicate your input model.&lt;/strong&gt; Genre and device are different axes, and portals mostly only have the genre one. Put it in your own text, where it travels with the game.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where the numbers came from, and what they cannot tell you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;All figures measured on &lt;strong&gt;5 August 2026&lt;/strong&gt;, in Chrome on Windows.&lt;/li&gt;
&lt;li&gt;The catalogue audit reads the game registry directly. &lt;strong&gt;200 entries in source, 120 live&lt;/strong&gt;; 155 with a control list, 45 without.&lt;/li&gt;
&lt;li&gt;The regex used to find candidate entries produced &lt;strong&gt;one false positive&lt;/strong&gt;, which is worth naming because it is the kind that looks like a finding: the pattern &lt;code&gt;friend&lt;/code&gt; matched the ordinary phrase &lt;strong&gt;"user-friendly"&lt;/strong&gt; and pulled in an entry that mentions no second player at all. Every one of the sixteen was read by hand before it was counted, and the hand count is what is published.&lt;/li&gt;
&lt;li&gt;Publisher pages were read directly for the nine group-A games plus two of the truncated entries. Only the controls section of each was used.&lt;/li&gt;
&lt;li&gt;Geometry was measured on a live page in same-origin frames at five viewport sizes. These are &lt;strong&gt;layout measurements at phone widths, not tests on phone hardware&lt;/strong&gt; — the machine reports zero touch points, so no claim here is based on a real touch device.&lt;/li&gt;
&lt;li&gt;The accessibility figure is from the W3C recommendation, read this run. No other platform guideline is quoted, because the two we tried to verify would not render their own text to a fetch, and an unverified number is worse than a missing one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What this cannot tell you:&lt;/strong&gt; whether any specific game plays well, or at all, with two people or one, on any device. Documentation is not behaviour. Nothing here is a review.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you make browser games
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://slowden.com/" rel="noopener noreferrer"&gt;SlowDen&lt;/a&gt; lists browser games and takes submissions from developers. If you have a playable web build you can &lt;a href="https://slowden.com/submit/" rel="noopener noreferrer"&gt;send it to us&lt;/a&gt; — and if two people need to share a keyboard for it, say so, because we would rather put that on the page than let someone find out on a bus.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://slowden.com/slow-cook/" rel="noopener noreferrer"&gt;Slow Cook&lt;/a&gt; is our creative challenge for developers: pick a challenge, build a small game around it, and submit it before the round closes on &lt;strong&gt;31 August 2026&lt;/strong&gt;. Selected entries may receive a shoutout on SlowDen's social accounts and a chance to be featured — nothing is guaranteed, and every entry is read by a person.&lt;/p&gt;

&lt;h3&gt;
  
  
  Related reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dev.to/liza_18827c15bdd387c4cd9c/why-your-browser-game-is-unplayable-on-a-phone-7hi"&gt;Why your browser game is unplayable on a phone&lt;/a&gt; — this morning's wider audit of touch support across the same catalogue, and the article corrected above.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://slowden.com/blog/why-your-browser-game-loads-to-a-black-screen/" rel="noopener noreferrer"&gt;Why your browser game loads to a black screen&lt;/a&gt; — what a page does to a game before anyone presses anything.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://slowden.com/blog/which-browser-game-portals-have-players/" rel="noopener noreferrer"&gt;Which browser-game portals actually have players on them&lt;/a&gt; — where to list once the game survives first contact.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://slowden.com/blog/how-to-submit-your-browser-game-to-slowden/" rel="noopener noreferrer"&gt;How to submit your browser game to SlowDen&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gamedev</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>a11y</category>
    </item>
    <item>
      <title>Why your browser game is unplayable on a phone</title>
      <dc:creator>Liza Bhadana</dc:creator>
      <pubDate>Tue, 04 Aug 2026 19:00:50 +0000</pubDate>
      <link>https://dev.to/liza_18827c15bdd387c4cd9c/why-your-browser-game-is-unplayable-on-a-phone-7hi</link>
      <guid>https://dev.to/liza_18827c15bdd387c4cd9c/why-your-browser-game-is-unplayable-on-a-phone-7hi</guid>
      <description>&lt;p&gt;There is a specific way a web game fails that almost never shows up in a bug report, because the person who hit it did not think of it as a bug. They opened your game on a phone, tapped where the page told them to tap, and then found that the thing they were holding could not send the inputs your game was waiting for. They did not complain. They left.&lt;/p&gt;

&lt;p&gt;This article measures how often that happens across a real catalogue of 200 HTML5 games, and what the page around the game does to make it worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;We audited the developer-written control list of every game in our catalogue. Of the 155 that have one, &lt;strong&gt;68 never mention touch, tapping, swiping or a phone at all&lt;/strong&gt;, and &lt;strong&gt;26 name a keyboard key as the movement control with no touch alternative&lt;/strong&gt; — nine of those expect &lt;em&gt;two people sharing one keyboard&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Meanwhile the page around the game says the opposite. On all &lt;strong&gt;14&lt;/strong&gt; live pages for those keyboard-driven games, the copy invites the player to &lt;em&gt;tap&lt;/em&gt;, all 14 then list a keyboard key, and &lt;strong&gt;none&lt;/strong&gt; of the 14 mentions touch anywhere. And the box any on-screen controls would have to fit into measured &lt;strong&gt;331 by 207 CSS pixels&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we measured, and what these numbers are not
&lt;/h2&gt;

&lt;p&gt;Everything below was measured on &lt;strong&gt;5 August 2026&lt;/strong&gt;, in Chrome on Windows, against live pages on slowden.com and against our own game registry. We are the portal in this article. That is deliberate: the interesting data here belongs to whoever runs the host page, and it is easier to publish honestly when the unflattering findings are your own.&lt;/p&gt;

&lt;p&gt;Two separate sources, measured two different ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The catalogue audit.&lt;/strong&gt; Our registry holds &lt;strong&gt;200&lt;/strong&gt; games, of which &lt;strong&gt;120&lt;/strong&gt; are live on the site today. Each entry can carry a &lt;code&gt;controls&lt;/code&gt; list, transcribed from that game's own public page on Playgama, the partner catalogue SlowDen lists through. &lt;strong&gt;155&lt;/strong&gt; entries have one; 45 have none. These are the &lt;em&gt;developers' and publisher's&lt;/em&gt; words, not ours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The live page measurements.&lt;/strong&gt; Geometry was measured by loading live pages into a same-origin frame of an exact viewport size and reading real bounding boxes. Computed styles, iframe attributes and page copy were read from the live documents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The most important caveat, stated before the numbers rather than after them: the audit measures documentation, not games.&lt;/strong&gt; A game may accept touch perfectly well and simply not say so. Another may say so and handle it badly. Nothing here is a claim about whether any specific game works on your phone. What it does measure is what a player can find out before they commit — and that is its own problem, because a player deciding whether to start has nothing to go on except the page.&lt;/p&gt;

&lt;p&gt;We also corrected ourselves twice while doing this, and both corrections are worth knowing if you run the same audit. Our first keyword pass classified &lt;code&gt;Smartphone controls:&lt;/code&gt; as having no phone mention, because a word-boundary match on "phone" does not match inside "Smartphone". It also counted the ordinary English word "space" — as in "free up space in the warehouse" — as the space bar. The load-bearing figure below is therefore a list of entries read by hand, not a regular expression's output, and five entries are excluded with a written reason each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: 68 of 155 control lists never mention touch
&lt;/h2&gt;

&lt;p&gt;Here is the whole catalogue, split by what the developer wrote about input.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Share of the 155 with a list&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Documents some touch or phone input path&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;87&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;56.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;… of which names an on-screen or virtual control&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;… of which splits "Desktop:" from "Mobile:" explicitly&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mentions no touch input of any kind&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;68&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;43.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;… of which names a keyboard key as the movement control &lt;em&gt;(hand-verified)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;26&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;16.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Has no control list at all&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;&lt;em&gt;of 200 entries&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 87 in the first row are the good news, and the shape of the good news is worth copying. Thirty of them do the single most useful thing a control list can do: they write two labelled sections, one for desktop and one for phones. &lt;em&gt;"Desktop: the snake turns with the cursor. Mobile: use the joystick to rotate the snake."&lt;/em&gt; That is Snake 2048 by TurtleGamesStudio, and it takes eleven extra words to tell a phone player that the game is for them.&lt;/p&gt;

&lt;p&gt;The 45 entries with no list at all are a quieter version of the same problem. A player on a phone gets nothing: not a control scheme, not a warning, not a "works best on desktop". They find out by starting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: 26 games hand the player a keyboard, and nine hand out two
&lt;/h2&gt;

&lt;p&gt;This is the group where the absence of a touch path is not a documentation gap but a wall. Twenty-six control lists name a keyboard key as the way you move, and mention no alternative. Fourteen of those games are live on the site today; twelve are in the catalogue but not yet published.&lt;/p&gt;

&lt;p&gt;A phone has no W, no A, no S, no D. It has no shift key to sprint with, no space bar to jump with, and no separate left and right mouse buttons to shoot and aim with. If a game's only documented path through its first thirty seconds runs through those, then for a phone player there is no path.&lt;/p&gt;

&lt;p&gt;The concentration by genre is not random.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Keyboard-driven, no touch listed&lt;/th&gt;
&lt;th&gt;Entries with a control list&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Horror&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block &amp;amp; Sandbox&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Racing&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sports&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.io Games&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Funny&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shooting&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;For Girls&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 Player&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Puzzle&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Puzzle games score zero, and that is the useful signal in the table rather than an accident. A puzzle is usually driven by pointing at a thing, and a tap is a point. First-person horror and 3D sandbox games are driven by simultaneous movement plus a look direction plus an action, which is four fingers' worth of intent on a surface that has room for two thumbs.&lt;/p&gt;

&lt;h3&gt;
  
  
  The nine that cannot be ported at all as designed
&lt;/h3&gt;

&lt;p&gt;Nine of the 26 are two-players-on-one-keyboard games. &lt;strong&gt;Stickman Kombat 2D&lt;/strong&gt; by Ernar (Tempo Play) gives Player 1 WASD plus F and G, and Player 2 IJKL plus a colon and a quote key. &lt;strong&gt;Escape School Duel&lt;/strong&gt; and &lt;strong&gt;Catch a Fish Obby&lt;/strong&gt;, both by gameVgames, split the board the same way. &lt;strong&gt;Soccer Random&lt;/strong&gt; and &lt;strong&gt;Basket Random&lt;/strong&gt; by RHM Interactive are the elegant extreme: one key each, W for Player 1 and the up arrow for Player 2.&lt;/p&gt;

&lt;p&gt;These are not games that need touch controls added. The &lt;em&gt;mode&lt;/em&gt; is two people at one keyboard, and a phone has one screen roughly the size of two hands. Same-device local multiplayer is genuinely one of the nicest things a browser game can do, and it is one of the few designs where "this one is for desktop" is a legitimate answer — provided somebody says so on the page. Nobody does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: the page says tap, then lists the keys
&lt;/h2&gt;

&lt;p&gt;This is our fault, not a developer's, and it is the finding that made this article worth writing.&lt;/p&gt;

&lt;p&gt;We fetched the live page for all 14 of those keyboard-driven games that are published today. All 14 returned HTTP 200. Then we read what each page tells a player.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the live page does&lt;/th&gt;
&lt;th&gt;Pages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Invites the player to &lt;em&gt;tap&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14 of 14&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Carries a Controls section&lt;/td&gt;
&lt;td&gt;14 of 14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Names a keyboard key in it&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14 of 14&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mentions touch, swipe, joystick, mobile or phone anywhere&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 of 14&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Take Moto X3M by MadPuffers, a Racing game and one of the 14. The page says: &lt;em&gt;"Wait for Moto X3M to finish loading in the player above. Click or tap inside the game window to start. Follow the in-game prompts — controls are listed below."&lt;/em&gt; Below it, the controls are: WASD or arrows, up arrow to accelerate, left and right to balance, down arrow to brake.&lt;/p&gt;

&lt;p&gt;So the page invites a tap, and then describes a game played with four keys. A player who followed the instruction exactly did the right thing and still ended up stuck. Nothing on that page is a lie in isolation; the combination is the defect. It is generated from one template, so it is the same defect 14 times.&lt;/p&gt;

&lt;p&gt;If you list your game on any portal, this is worth knowing for a blunt reason: &lt;strong&gt;the host's page copy will be written by a template that has never read your control scheme.&lt;/strong&gt; Whatever your game needs, assume the page will not say it. The only place a phone player can reliably be told is inside your game's own first frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: the box your on-screen controls have to fit into
&lt;/h2&gt;

&lt;p&gt;Suppose a developer does the right thing and adds touch controls. How much room do they get? We measured the same live game page at five widths by loading it into a same-origin frame at each exact size.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Viewport (CSS px)&lt;/th&gt;
&lt;th&gt;Game box&lt;/th&gt;
&lt;th&gt;Box area&lt;/th&gt;
&lt;th&gt;Share of viewport area&lt;/th&gt;
&lt;th&gt;Page above the box&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;360 × 740&lt;/td&gt;
&lt;td&gt;301 × 188&lt;/td&gt;
&lt;td&gt;56,550 px2&lt;/td&gt;
&lt;td&gt;21.2%&lt;/td&gt;
&lt;td&gt;283 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;390 × 844&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;331 × 207&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;68,558 px2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20.8%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;283 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;412 × 915&lt;/td&gt;
&lt;td&gt;353 × 221&lt;/td&gt;
&lt;td&gt;77,792 px2&lt;/td&gt;
&lt;td&gt;20.6%&lt;/td&gt;
&lt;td&gt;259 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;430 × 932&lt;/td&gt;
&lt;td&gt;371 × 232&lt;/td&gt;
&lt;td&gt;86,118 px2&lt;/td&gt;
&lt;td&gt;21.5%&lt;/td&gt;
&lt;td&gt;259 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;768 × 1024&lt;/td&gt;
&lt;td&gt;707 × 442&lt;/td&gt;
&lt;td&gt;312,494 px2&lt;/td&gt;
&lt;td&gt;39.7%&lt;/td&gt;
&lt;td&gt;237 px&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across every phone width the answer is remarkably stable: the game gets about &lt;strong&gt;one fifth of the screen&lt;/strong&gt;. The box is width-driven with a fixed aspect ratio, so it scales with the page rather than with the space available, and the share barely moves between a small Android phone and a large iPhone.&lt;/p&gt;

&lt;p&gt;The starker number is the page rather than the screen. At 390 by 844, the full document is &lt;strong&gt;4,086 CSS pixels tall&lt;/strong&gt; — 4.8 screenfuls — with 283 pixels of page above the game and &lt;strong&gt;3,596 pixels below it&lt;/strong&gt;. Your game is &lt;strong&gt;5.07% of the height of the page it lives on&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What that means for a virtual joystick
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html" rel="noopener noreferrer"&gt;WCAG 2.2 Success Criterion 2.5.8, Target Size (Minimum)&lt;/a&gt;, Level AA, requires that "the size of the target for pointer inputs is at least 24 by 24 CSS pixels". That is an accessibility floor for any tappable target, not a size anyone would choose for a control held down mid-action. Using it as a floor, and counting the distinct inputs each control list names by hand:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Game (studio)&lt;/th&gt;
&lt;th&gt;Distinct inputs named&lt;/th&gt;
&lt;th&gt;At the 24 × 24 floor&lt;/th&gt;
&lt;th&gt;Share of a 331 × 207 box&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Moto X3M (MadPuffers)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2,304 px2&lt;/td&gt;
&lt;td&gt;3.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Tall Man (Sleepless Games)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;3,456 px2&lt;/td&gt;
&lt;td&gt;5.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Escape from the Portal (Mirra Games)&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;6,912 px2&lt;/td&gt;
&lt;td&gt;10.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grand Shift Auto (oneru220)&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;8,640 px2&lt;/td&gt;
&lt;td&gt;12.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hazmob FPS: Online Shooter (Hazmob)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;19&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10,944 px2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that table the pessimistic way round. Those percentages are the &lt;em&gt;best possible case&lt;/em&gt; — controls shrunk to the smallest size an accessibility standard will tolerate, packed with no gaps, and every one of those pixels still sitting on top of the game the player is trying to see.&lt;/p&gt;

&lt;p&gt;Width is the tighter constraint than area, and this next line is an illustration rather than a measurement, because we have no measured figure for a comfortable thumb zone: if you allow two 120-pixel zones for two thumbs, that is 240 of the box's 331 pixels of width and 120 of its 207 pixels of height. The controls stop being an overlay on the game and become most of it.&lt;/p&gt;

&lt;p&gt;The conclusion we draw is not "draw smaller buttons". It is that &lt;strong&gt;a game with nineteen inputs does not become a touch game by growing nineteen buttons.&lt;/strong&gt; The games that survive the move are redesigned down to one or two simultaneous inputs, which is a design decision made early and cheaply, or very late and expensively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 5: nothing on the page is protecting your gestures
&lt;/h2&gt;

&lt;p&gt;Say the controls fit. There is one more thing between a swipe and your game, and it is the default behaviour of the browser.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action" rel="noopener noreferrer"&gt;MDN's reference for the CSS &lt;code&gt;touch-action&lt;/code&gt; property&lt;/a&gt; puts it plainly: the initial value is &lt;code&gt;auto&lt;/code&gt;, and "by default, panning (scrolling) and pinching gestures are handled exclusively by the browser". Setting &lt;code&gt;touch-action: none&lt;/code&gt; is what hands those gestures to the page instead.&lt;/p&gt;

&lt;p&gt;We read the computed &lt;code&gt;touch-action&lt;/code&gt; of every element on a live game page. The count of elements restricting touch gestures in any way was &lt;strong&gt;zero&lt;/strong&gt;. The root element, the body, the embed container and the frame itself are all &lt;code&gt;auto&lt;/code&gt;. And directly below that container sits 3,596 pixels of scrollable page.&lt;/p&gt;

&lt;p&gt;So a vertical drag that starts on the game and is not claimed by the game is a drag the browser is entitled to read as a scroll — and the page will happily oblige by moving the game off the screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This one you have to fix yourself, and that is not a complaint about hosts.&lt;/strong&gt; A portal cannot reach inside a cross-origin frame to set &lt;code&gt;touch-action&lt;/code&gt; on your canvas — the isolation that stops a host reading your game also stops it helping. So the responsibility genuinely is the developer's. Two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* in your game, on whatever surface receives play input */&lt;/span&gt;
&lt;span class="nt"&gt;canvas&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.game-surface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;touch-action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// and register listeners so preventDefault is actually allowed&lt;/span&gt;
&lt;span class="nx"&gt;el&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;touchstart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onTouch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;passive&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="nx"&gt;el&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;touchmove&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nx"&gt;onMove&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;passive&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chrome treats &lt;code&gt;touchstart&lt;/code&gt; and &lt;code&gt;touchmove&lt;/code&gt; listeners on the document as passive by default, which means &lt;code&gt;preventDefault()&lt;/code&gt; inside them is ignored unless you opt out explicitly. A game that calls &lt;code&gt;preventDefault()&lt;/code&gt; and still gets scrolled is usually hitting exactly this.&lt;/p&gt;

&lt;p&gt;For completeness, the host side of this particular page is not doing anything hostile: the frame carries &lt;code&gt;allowfullscreen&lt;/code&gt; and an &lt;code&gt;allow&lt;/code&gt; attribute with four tokens including fullscreen, and there is no &lt;code&gt;sandbox&lt;/code&gt; attribute. The one genuinely mobile-friendly control on the page is a &lt;em&gt;Play full screen&lt;/em&gt; link measuring 170 by 44 CSS pixels, which comfortably clears the 24-pixel floor and does sit above the fold at 390 by 844. It opens the game in a new tab rather than calling the Fullscreen API — &lt;code&gt;requestFullscreen&lt;/code&gt; appears nowhere in the page's source — but for a phone player the practical effect is the right one: the game gets the whole tab instead of a fifth of the screen. It is also, by some distance, the least prominent thing on the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The counter-example, and it is not one of ours either
&lt;/h2&gt;

&lt;p&gt;The Cube is the open-source Rubik's-cube project by &lt;strong&gt;Boris Sehovac&lt;/strong&gt; (&lt;a href="https://github.com/bsehovac/the-cube" rel="noopener noreferrer"&gt;bsehovac/the-cube&lt;/a&gt;, built with Three.js; no licence is stated in the repository, so we host it with credit and link the source). It is not a SlowDen game and we do not claim it. It is here because it gets everything in this article right by construction.&lt;/p&gt;

&lt;p&gt;We read its whole game file — &lt;code&gt;js.js&lt;/code&gt;, 101,851 bytes, re-fetched with &lt;code&gt;cache: 'no-store'&lt;/code&gt; — and counted its input handling.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Handler&lt;/th&gt;
&lt;th&gt;Occurrences&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;touchstart&lt;/code&gt; / &lt;code&gt;touchmove&lt;/code&gt; / &lt;code&gt;touchend&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;4 / 4 / 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;mousedown&lt;/code&gt; / &lt;code&gt;mousemove&lt;/code&gt; / &lt;code&gt;mouseup&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;3 / 2 / 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;keydown&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reads &lt;code&gt;touches&lt;/code&gt; and &lt;code&gt;changedTouches&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero keyboard handlers. Both pointer paths bound, in parallel, with the touch path reading real touch lists rather than hoping a mouse event will be synthesised for it. Its opening prompt is &lt;em&gt;Double tap to start&lt;/em&gt; — an instruction that is true on a phone and true on a laptop, which is a harder thing to write than it looks.&lt;/p&gt;

&lt;p&gt;And because it is self-hosted rather than embedded, its page is the game: the canvas measured 1,536 by 695, the document height equals the viewport height exactly, the body has &lt;code&gt;overflow: hidden&lt;/code&gt;, and the page is not scrollable. There is no page below it to scroll to. The same game inside a portal embed would have got about a fifth of the screen and 3,596 pixels of page underneath it.&lt;/p&gt;

&lt;p&gt;That contrast is the whole argument in one comparison. Nothing about touch support requires a big screen. It requires that somebody decided which inputs the game has before deciding how many buttons to draw.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eight things to change, roughly in order of payoff
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Open your build on an actual phone and finish the first thirty seconds without a keyboard.&lt;/strong&gt; Not a narrow desktop window — that still delivers key events and a mouse. If you cannot get through the first level, neither can a fifth of the people who find you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count your inputs, then cut.&lt;/strong&gt; Write down every distinct action your first level needs. If the list is longer than two simultaneous inputs, the phone version is a redesign, not a control overlay. Deciding that in week one is cheap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;touch-action: none&lt;/code&gt; on your play surface and register touch listeners with &lt;code&gt;{ passive: false }&lt;/code&gt;.&lt;/strong&gt; Otherwise the browser keeps the pan gesture and the host page scrolls out from under your player.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle pointer events, not mouse events.&lt;/strong&gt; A single &lt;code&gt;pointerdown&lt;/code&gt; / &lt;code&gt;pointermove&lt;/code&gt; path covers mouse, touch and pen. If you prefer explicit touch handlers, bind both and read &lt;code&gt;changedTouches&lt;/code&gt; rather than assuming a synthesised mouse event will arrive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design your first frame for 331 by 207 CSS pixels.&lt;/strong&gt; Not as a stretch goal — as the default. Set your editor to that size once and look at your title screen in it. Most title screens do not survive the experience, and it is better to find that out yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say what the game needs, inside the game.&lt;/strong&gt; One line on the title screen: "drag to steer, tap to jump", or honestly, "this one needs a keyboard". Do not rely on the host page — we have just shown ours saying the opposite 14 times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make every target at least 24 by 24 CSS pixels, and much larger for anything held down.&lt;/strong&gt; The 24-pixel figure is WCAG 2.2's minimum, not a target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If your game really is desktop-only, say so proudly rather than quietly.&lt;/strong&gt; Two players sharing one keyboard is a genuinely good design. It just needs a label, so a phone player finds out before they start rather than after.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Run this on your own listing
&lt;/h2&gt;

&lt;p&gt;Paste this into the console on any portal page that embeds your game. It reports the geometry your game actually gets and whether anything on the page is reserving touch gestures for it.&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="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;f&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="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;iframe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;f&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no iframe on this page&lt;/span&gt;&lt;span class="dl"&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;r&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&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;vw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;innerWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;innerHeight&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;doc&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;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&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;restricted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&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;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getComputedStyle&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;touchAction&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;viewport&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                 &lt;span class="nx"&gt;vw&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; x &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;vh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your frame&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;               &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&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; x &lt;/span&gt;&lt;span class="dl"&gt;'&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;frame area&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;               &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&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; px2&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;% of viewport area&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&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="nx"&gt;vw&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;vh&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;toFixed&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&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;page above the frame&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&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; px&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;page below the frame&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bottom&lt;/span&gt;&lt;span class="p"&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; px&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;frame as % of page&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;toFixed&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&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;elements reserving touch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;restricted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allowfullscreen&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allowfullscreen&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;sandboxed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sandbox&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="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;elements reserving touch&lt;/code&gt; comes back as &lt;code&gt;0&lt;/code&gt; and &lt;code&gt;page below the frame&lt;/code&gt; is in the thousands, then a swipe your game does not claim will scroll your game off the screen. That is the host's layout and your &lt;code&gt;touch-action&lt;/code&gt;, together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways this measurement tried to fool us
&lt;/h2&gt;

&lt;p&gt;Both worth knowing if you automate any of this, because both produced a confident wrong answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A hidden element that every DOM check called visible.&lt;/strong&gt; We tried to test whether The Cube starts from touch input by watching its &lt;em&gt;Double tap to start&lt;/em&gt; prompt disappear. After a real double-click the prompt was plainly gone from the screen — and every programmatic check still reported it present. Its computed &lt;code&gt;display&lt;/code&gt; was &lt;code&gt;block&lt;/code&gt;, its &lt;code&gt;visibility&lt;/code&gt; was &lt;code&gt;visible&lt;/code&gt;, &lt;code&gt;checkVisibility()&lt;/code&gt; returned &lt;code&gt;true&lt;/code&gt;, and &lt;code&gt;innerText&lt;/code&gt; still contained the string. The element was faded to &lt;code&gt;opacity: 0.000107&lt;/code&gt;. Only the rendered pixels told the truth. If you are asserting on game state from the DOM, assert on something the game sets, never on whether text looks gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synthetic events did not drive the game, and the control proved it.&lt;/strong&gt; Our dispatched &lt;code&gt;TouchEvent&lt;/code&gt; pairs did nothing. That looks like evidence the game ignores touch — until you run the control, which we did: synthetic &lt;code&gt;MouseEvent&lt;/code&gt; pairs did nothing either, on the canvas and on the document, while a real trusted double-click started the game immediately. So the failure was our dispatch method, not the game's input handling. &lt;strong&gt;The touch test is therefore inconclusive and we are reporting it as inconclusive.&lt;/strong&gt; The reliable evidence for The Cube's touch support is the handler count read out of its source, above — not our test.&lt;/p&gt;

&lt;p&gt;A negative result from an automated harness is worth roughly nothing without a positive control run the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where these numbers came from, and their limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Documentation, not games.&lt;/strong&gt; Said once more because it bounds everything: the 87 / 68 / 26 split describes what developers wrote about their controls. It is not a test of any game, and no game named here is being called broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 26 is a hand-read list, and its judgement calls are written down.&lt;/strong&gt; Five entries were deliberately excluded, each with a reason — one control list is actually an FAQ that names no input at all; one says "click on the arrows", where the arrows are objects in the puzzle rather than keys; three are click-driven with a key as a secondary extra, so a tap substitutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phone rows are layout measurements, not device tests.&lt;/strong&gt; A same-origin frame at 390 by 844 gives real CSS-pixel geometry, but no mobile user agent, no device pixel ratio, no collapsing address bar and no real finger. Treat them as the layout a phone would get, not as a device report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source versus live, labelled.&lt;/strong&gt; The registry holds 200 entries; 120 are live on the site today. Of the 26 keyboard-driven games, 14 are live and 12 are not yet published. The 14-of-14 page-copy finding was measured against live pages only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One browser.&lt;/strong&gt; Chrome on Windows. Safari in particular handles touch defaults and viewport units differently; do not assume these figures transfer without testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 120-pixel thumb zone is an illustration, not a measurement&lt;/strong&gt;, and is labelled as such where it appears. The 24-by-24 figure is not an illustration — it is quoted from WCAG 2.2 SC 2.5.8.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credits.&lt;/strong&gt; Every game named is credited to its studio as recorded on its own public listing. All of them are partner titles listed on SlowDen through Playgama; none was made by SlowDen. The Cube is by Boris Sehovac, open source, self-hosted with credit and a link to the repository. Playgama, Chrome, Three.js, W3C and MDN are named for accuracy only; no affiliation or endorsement is implied.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you make browser games
&lt;/h2&gt;

&lt;p&gt;SlowDen lists browser games and takes submissions from developers. If you have a playable web build, you can &lt;a href="https://slowden.com/submit/" rel="noopener noreferrer"&gt;send it to us&lt;/a&gt; — and if it needs a keyboard, tell us, because we would rather write that on the page than let a player find out.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://slowden.com/slow-cook/" rel="noopener noreferrer"&gt;Slow Cook&lt;/a&gt; is our creative challenge for developers: pick a challenge, build a small game around it, and submit it before the round closes on &lt;strong&gt;31 August 2026&lt;/strong&gt;. Selected entries may receive a shoutout on SlowDen's social accounts and a chance to be featured — nothing is guaranteed, and every entry is read by a person.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://slowden.com/blog/why-your-browser-game-loads-to-a-black-screen/" rel="noopener noreferrer"&gt;Why your browser game loads to a black screen&lt;/a&gt; — the load-weight version of the same story, with a 203 MB preload queue taken apart.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/liza_18827c15bdd387c4cd9c/why-players-leave-before-your-game-starts-457o"&gt;Why players leave before your game starts&lt;/a&gt; — the geometry and timing of the window before a game runs, measured at seven viewport sizes.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://slowden.com/blog/which-browser-game-portals-have-players/" rel="noopener noreferrer"&gt;Which browser-game portals actually have players on them&lt;/a&gt; — where to list once the game survives first contact.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://slowden.com/blog/how-browser-game-search-differs-by-country/" rel="noopener noreferrer"&gt;What players in six countries ask a browser game site to do&lt;/a&gt; — and how much of that demand arrives on a phone.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>gamedev</category>
      <category>javascript</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Why players leave before your game starts</title>
      <dc:creator>Liza Bhadana</dc:creator>
      <pubDate>Mon, 03 Aug 2026 10:33:56 +0000</pubDate>
      <link>https://dev.to/liza_18827c15bdd387c4cd9c/why-players-leave-before-your-game-starts-457o</link>
      <guid>https://dev.to/liza_18827c15bdd387c4cd9c/why-players-leave-before-your-game-starts-457o</guid>
      <description>&lt;p&gt;Your game is listed on a portal. The page gets visits. The play count does not move.&lt;/p&gt;

&lt;p&gt;It is tempting to read that as a verdict on the game — but nobody who left ever played it. Whatever they rejected, they rejected &lt;em&gt;before the game started&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This article measures that window on a live portal, in numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The short version.&lt;/strong&gt; On a portal, the first ten seconds of your game are not yours. The host's CSS decides how large your game is. The host's page decides how much of it is on screen. Your own first frame decides whether either of those mattered. I measured all three on a live site — ours — and found a game box &lt;strong&gt;330 by 205 pixels&lt;/strong&gt; on a phone, &lt;strong&gt;several seconds of black rectangle&lt;/strong&gt; before anything was drawn, and a start button that landed &lt;strong&gt;at the bottom edge of a laptop viewport&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I measured, and on what
&lt;/h2&gt;

&lt;p&gt;Everything below was measured on &lt;strong&gt;3 August 2026&lt;/strong&gt;, in Chrome on Windows, against live pages on slowden.com. We are the portal in this article, which is rather the point: this is data a developer cannot normally get, because it belongs to the host — and it is easier to publish honestly when the embarrassing parts are your own.&lt;/p&gt;

&lt;p&gt;Three games, none of them made by SlowDen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hedgies&lt;/strong&gt; — a Playgama partner game by RedSpell, embedded in a cross-origin frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snake 2048&lt;/strong&gt; — a Playgama partner game by TurtleGamesStudio, in the same container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Cube&lt;/strong&gt; — the open-source Rubik's-cube project by Boris Šehovac, self-hosted with credit rather than embedded. It appears here as the counter-example.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Layout was measured by loading each live page into a same-origin frame set to an exact viewport size and reading the real bounding boxes, rather than by eyeballing a browser window. Timings come from the Navigation Timing and Resource Timing APIs on the live pages. Byte sizes were re-fetched with &lt;code&gt;cache: 'no-store'&lt;/code&gt; and measured from the response body, because a cached resource reports zero bytes in Resource Timing and would have made the totals look far better than they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: the box is much smaller than you are designing for
&lt;/h2&gt;

&lt;p&gt;The container is one line of CSS: a full-width box with a fixed 16:10 aspect ratio. That means the height of your game is decided entirely by how wide the page happens to be.&lt;/p&gt;

&lt;p&gt;Here is the same game page at seven viewport sizes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Viewport (CSS px)&lt;/th&gt;
&lt;th&gt;Game box&lt;/th&gt;
&lt;th&gt;Box height vs viewport&lt;/th&gt;
&lt;th&gt;Visible without scrolling&lt;/th&gt;
&lt;th&gt;Page above the game&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;360 × 740&lt;/td&gt;
&lt;td&gt;299 × 186&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;284 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;390 × 844&lt;/td&gt;
&lt;td&gt;330 × 205&lt;/td&gt;
&lt;td&gt;24%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;260 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;412 × 915&lt;/td&gt;
&lt;td&gt;351 × 219&lt;/td&gt;
&lt;td&gt;24%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;260 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;768 × 1024&lt;/td&gt;
&lt;td&gt;707 × 441&lt;/td&gt;
&lt;td&gt;43%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;238 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1366 × 768&lt;/td&gt;
&lt;td&gt;1054 × 658&lt;/td&gt;
&lt;td&gt;86%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;79%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;251 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1536 × 695&lt;/td&gt;
&lt;td&gt;1054 × 658&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;67%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;251 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1920 × 1080&lt;/td&gt;
&lt;td&gt;1054 × 658&lt;/td&gt;
&lt;td&gt;61%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;251 px&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things fall out of that table, and neither is obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On a phone your game is a postage stamp.&lt;/strong&gt; 330 by 205 CSS pixels is about a quarter of the screen. If your first frame carries a logo, a tagline, a difficulty selector and a start button, none of it is legible. Developers routinely build a title screen in a 1280 by 720 canvas and never look at it at 330 by 205 — but that is the size a large share of players will meet it at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The worst clipping is not on mobile, it is on a laptop.&lt;/strong&gt; At 1536 by 695 — an extremely common real-world viewport once browser chrome is subtracted — the box is 658 pixels tall and sits 251 pixels down the page, so a third of your game is below the fold on arrival. The phone cases were not clipped at all, because the box was too small to be clipped.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The design rule this implies.&lt;/strong&gt; Your first frame has to work in a landscape box roughly 300 pixels wide, and it has to put anything important in the &lt;em&gt;upper middle&lt;/em&gt;, because the bottom third may not be on screen. That is a much tighter brief than the one most title screens are designed to.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Finding 2: for the first several seconds the player sees a black rectangle
&lt;/h2&gt;

&lt;p&gt;Here is the load sequence for one game page, taken from Navigation and Resource Timing on the live site:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Moment&lt;/th&gt;
&lt;th&gt;Time from navigation start&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Host page HTML finished arriving (13,371 bytes decoded)&lt;/td&gt;
&lt;td&gt;154 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web fonts finished (5 requests, 98 KB)&lt;/td&gt;
&lt;td&gt;323 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host page DOM complete&lt;/td&gt;
&lt;td&gt;323 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Game frame's &lt;em&gt;document&lt;/em&gt; finished arriving&lt;/td&gt;
&lt;td&gt;1,260 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host page load event&lt;/td&gt;
&lt;td&gt;1,264 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On a second game in the same container, the host page fired its load event at &lt;strong&gt;726 ms&lt;/strong&gt; while the game frame's own resources were still landing at roughly &lt;strong&gt;4.5 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the host page is finished and painted in about a third of a second, and the game is not. What fills the gap is a black box. I reloaded both pages repeatedly and screenshotted: immediately after load, the game area was a solid black rectangle with no play control anywhere on the page, and the partner player's own splash screen appeared only several seconds later.&lt;/p&gt;

&lt;p&gt;You cannot remove that window. You can decide what is in it. A first frame that draws a background colour, the game's name and a progress indicator within a few hundred milliseconds is doing something no host can do for you, because the host cannot see inside your frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: your start button is not where you think it is
&lt;/h2&gt;

&lt;p&gt;You choose where the start control sits inside your frame. The host chooses where the frame sits on the page. Nobody reconciles those two decisions, and the result is measurable.&lt;/p&gt;

&lt;p&gt;Two games, same container, same portal, same viewport of 1568 by 688:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Snake 2048&lt;/strong&gt; renders &lt;em&gt;Tap to start&lt;/em&gt; near the vertical centre of the frame. It landed around y=590, comfortably on screen. This one works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hedgies&lt;/strong&gt; renders its &lt;em&gt;Let's Play!&lt;/em&gt; button low in the frame. It landed at the very bottom edge of the viewport, clipped by the fold. A visitor who does not scroll never sees the control that starts the game.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither studio did anything wrong. Both start screens are perfectly sensible in isolation. The failure only exists in combination — and the combination is the only thing the player ever experiences.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What we changed on our side.&lt;/strong&gt; Publishing this without saying what we did with it would be cheap. Our game pages now paint their own thumbnail and a real play button immediately, and create the game frame only when someone clicks it, so the page can never again show a black box with no control on it. That change is in our source and is &lt;strong&gt;not deployed as of this article's publication date&lt;/strong&gt; — the pages measured above are still live in the state described. I am not going to pretend otherwise.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Finding 4: the page around your game spends the budget first
&lt;/h2&gt;

&lt;p&gt;On the page measured above, the host's own HTML was 13,371 bytes. Its web fonts were &lt;strong&gt;98 KB across five requests&lt;/strong&gt;. Those fonts were fully downloaded at 323 ms; the game's frame document did not finish until 1,260 ms.&lt;/p&gt;

&lt;p&gt;You do not control that and you should not try to. But it is worth knowing, because it changes what your own budget is for. The player's patience is being spent on the portal's furniture before your first byte arrives, and whatever you were planning to spend on a splash animation is coming out of what is left.&lt;/p&gt;

&lt;h2&gt;
  
  
  A counter-example, on the same site
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The Cube&lt;/strong&gt; is not embedded — it is self-hosted on the same domain — so it is not a like-for-like comparison, and it is included for one specific reason: it shows what a fast first frame is actually made of.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Bytes (decoded, re-fetched with no-store)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;index.html&lt;/td&gt;
&lt;td&gt;7,144&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;style.css&lt;/td&gt;
&lt;td&gt;35,932&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;js.js (the whole game)&lt;/td&gt;
&lt;td&gt;101,851&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;three.min.js (from a CDN)&lt;/td&gt;
&lt;td&gt;646,271&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;791,198 ≈ 773 KB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;DOM complete at &lt;strong&gt;292 ms&lt;/strong&gt;, load event at &lt;strong&gt;303 ms&lt;/strong&gt;. What lands on screen is a finished, readable frame: the title, a rendered 3D cube, and the words &lt;em&gt;Double tap to start&lt;/em&gt;. Four files, no loading bar, nothing to wait for — and note that 82 percent of those bytes are the 3D engine, not the game. The game itself is 102 KB.&lt;/p&gt;

&lt;p&gt;The lesson is not "be under 800 KB". It is that a first frame worth showing can be very cheap, and that shipping one is a decision rather than a budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven things to do about it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Open your own title screen at 330 by 205 and look at it.&lt;/strong&gt; Not a mock-up — the real build, at that size. This takes two minutes and is the single highest-value item on this list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Draw something in the first frame.&lt;/strong&gt; A background colour and the game's name beats black. If you have a preload step, draw before you preload, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the start control in the upper middle of your frame.&lt;/strong&gt; The bottom third can be below the host's fold; the top is always on screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Show progress if loading exceeds about a second.&lt;/strong&gt; A bar that moves converts waiting into waiting-for-something. A static black box converts it into leaving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not gate the first frame on audio.&lt;/strong&gt; Audio starts suspended until the player interacts, so anything waiting on an audio context will wait forever on load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size your canvas after layout, not before.&lt;/strong&gt; A canvas measured before the frame has its final box gets zero or a stale size, and then renders into the wrong dimensions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-test after every portal listing.&lt;/strong&gt; The container is the host's and it can change without telling you.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Test your own game in ten minutes
&lt;/h2&gt;

&lt;p&gt;Open the portal page that hosts your game, open the console, and paste this. It measures the page rather than guessing at it.&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;// Paste into the console on the page that EMBEDS your game.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&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="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;iframe&lt;/span&gt;&lt;span class="dl"&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&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;nav&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntriesByType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;viewport&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;          &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;innerWidth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; x &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;innerHeight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your game box&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;     &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&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; x &lt;/span&gt;&lt;span class="dl"&gt;'&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page above game&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;   &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&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; px&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;visible on arrival&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;innerHeight&lt;/span&gt;&lt;span class="p"&gt;)&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&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;%&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;scroll to see all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;innerHeight&lt;/span&gt;&lt;span class="p"&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; px&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;host DOM done&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;     &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domContentLoadedEventEnd&lt;/span&gt;&lt;span class="p"&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; ms&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;host load event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;   &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loadEventEnd&lt;/span&gt;&lt;span class="p"&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; ms&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;lazy loaded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;       &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// When did YOUR frame's document actually arrive?&lt;/span&gt;
&lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntriesByType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resource&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&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;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&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;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responseEnd&lt;/span&gt;&lt;span class="p"&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; ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it at a few widths — drag the window narrow, then wide — because the layout changes and the clipping is worst at sizes you would not think to check. If &lt;code&gt;visible on arrival&lt;/code&gt; is under 100%, work out what is in the missing part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where these numbers came from
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When and where:&lt;/strong&gt; 3 August 2026, Chrome on Windows, against live pages on slowden.com. Every figure above is a reading, not an estimate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Viewport table:&lt;/strong&gt; produced by loading each live page into a same-origin frame of the stated size and reading real bounding boxes. This measures CSS layout faithfully, but it is &lt;em&gt;not&lt;/em&gt; a phone: there is no mobile user-agent, no device pixel ratio, and no collapsing address bar. Treat the mobile rows as the layout a phone-width viewport produces, not as a device test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timings:&lt;/strong&gt; Navigation Timing and Resource Timing on the live pages. I could &lt;em&gt;not&lt;/em&gt; measure first paint: the automated tab loads in the background, and Chrome does not record paint timing for a page that was never visible. Every timing quoted is therefore a network or DOM milestone, and none is presented as a paint time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inside the frame is invisible to me.&lt;/strong&gt; The game frame is cross-origin, so I can see when its document arrived but not what it drew. The claims about black rectangles and start-button positions come from screenshots of the rendered page, not from reading the frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Byte sizes:&lt;/strong&gt; re-fetched with &lt;code&gt;cache: 'no-store'&lt;/code&gt; and measured from the response body, so they are decoded sizes. On the wire they are compressed and smaller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One browser.&lt;/strong&gt; Chrome only. Do not assume Safari or Firefox behave identically without testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credits:&lt;/strong&gt; Hedgies is by RedSpell and Snake 2048 by TurtleGamesStudio, both listed on SlowDen through Playgama. The Cube is by Boris Šehovac, open source, hosted with credit. Most games listed on SlowDen are partner or third-party titles rather than SlowDen's own work. Playgama, Chrome and Three.js are named for accuracy only; no affiliation or endorsement is implied.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you make browser games
&lt;/h2&gt;

&lt;p&gt;SlowDen lists browser games and takes submissions from developers. If you have a playable web build, you can &lt;a href="https://slowden.com/submit/" rel="noopener noreferrer"&gt;send it to us&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://slowden.com/slow-cook/" rel="noopener noreferrer"&gt;Slow Cook&lt;/a&gt; is our creative challenge for developers: pick a challenge, build a small game around it, submit it before the round closes on &lt;strong&gt;31 August 2026&lt;/strong&gt;. Selected entries may receive a shoutout on SlowDen's social accounts and a chance to be featured — nothing is guaranteed, and every entry is read by a person.&lt;/p&gt;

&lt;p&gt;Related, if this was useful: &lt;a href="https://slowden.com/blog/why-your-browser-game-loads-to-a-black-screen/" rel="noopener noreferrer"&gt;Why your browser game loads to a black screen&lt;/a&gt; takes the same failure apart from inside one game, with a 203 MB preload queue. And &lt;a href="https://slowden.com/blog/which-browser-game-portals-have-players/" rel="noopener noreferrer"&gt;Which browser-game portals actually have players on them&lt;/a&gt; covers where to list once the first ten seconds work.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written for the SlowDen blog: &lt;a href="https://slowden.com/blog/" rel="noopener noreferrer"&gt;https://slowden.com/blog/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>webdev</category>
      <category>performance</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why your browser game loads to a black screen</title>
      <dc:creator>Liza Bhadana</dc:creator>
      <pubDate>Fri, 31 Jul 2026 13:50:10 +0000</pubDate>
      <link>https://dev.to/liza_18827c15bdd387c4cd9c/why-your-browser-game-loads-to-a-black-screen-4l8g</link>
      <guid>https://dev.to/liza_18827c15bdd387c4cd9c/why-your-browser-game-loads-to-a-black-screen-4l8g</guid>
      <description>&lt;p&gt;Here is a failure that almost nobody catches before shipping, because it does not look like a failure from the inside. Your game works perfectly on your machine. Every file returns &lt;code&gt;200&lt;/code&gt;. There is not a single error in the console. And a player who opens the link sits in front of a black rectangle for the better part of a minute, decides the site is broken, and closes the tab.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The short version.&lt;/strong&gt; A blank canvas with no errors is almost never a crash. It is a game that is still loading, in a framework that draws nothing until loading finishes. We took apart one of our own games to show exactly how a project ends up there — &lt;strong&gt;203 MB of art queued in front of 38 KB of game&lt;/strong&gt; — and what to measure in your own build to make sure it hasn't happened to you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why we're using our own game as the example
&lt;/h2&gt;

&lt;p&gt;It would be easy to write this article about somebody else's game. It would also be worthless, because you would have no way of checking any of it.&lt;/p&gt;

&lt;p&gt;So the example here is &lt;strong&gt;Pastanation&lt;/strong&gt;, a pixel-art pasta-cooking game built by SlowDen in &lt;a href="https://phaser.io/" rel="noopener noreferrer"&gt;Phaser&lt;/a&gt;. It is one of our own projects, it is live at &lt;a href="https://slowden.com/games/pastanation/" rel="noopener noreferrer"&gt;slowden.com/games/pastanation/&lt;/a&gt;, and it currently has exactly the problem this article is about. &lt;strong&gt;If you open it, expect a long wait on a blank screen before anything appears&lt;/strong&gt; — that is the bug, not a surprise. Every number below came from measuring that live page and the files it serves, on 31 July 2026, and you can re-measure all of them yourself.&lt;/p&gt;

&lt;p&gt;Writing this up is more useful than quietly fixing it, because the shape of the mistake is extremely common and almost invisible while you are making it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "no errors" actually tells you
&lt;/h2&gt;

&lt;p&gt;Here is the state of that page when a player loads it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every asset request returns &lt;strong&gt;HTTP 200&lt;/strong&gt;. Nothing is missing, nothing is 404ing, nothing is blocked.&lt;/li&gt;
&lt;li&gt;The console contains &lt;strong&gt;zero errors&lt;/strong&gt; — just Phaser's own version banner.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;1920×1280 canvas&lt;/strong&gt; is created and attached to the page exactly as intended.&lt;/li&gt;
&lt;li&gt;The player sees &lt;strong&gt;black&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the trap. Every diagnostic that a developer instinctively reaches for — the console, the error log, the network panel's red rows — reports that everything is fine. &lt;strong&gt;A green network tab is not evidence that your game works.&lt;/strong&gt; It is only evidence that your files exist.&lt;/p&gt;

&lt;p&gt;What none of those tools shout at you is the number that actually matters: &lt;em&gt;how many bytes must arrive before the first frame renders?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement: 203 MB of art in front of a 38 KB game
&lt;/h2&gt;

&lt;p&gt;Pastanation is 162 files and &lt;strong&gt;203.7 MB&lt;/strong&gt; in total. Broken down by what those bytes are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Share of total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;The game's own code&lt;/strong&gt; — every scene, every interaction, the whole design&lt;/td&gt;
&lt;td&gt;38 KB&lt;/td&gt;
&lt;td&gt;0.02%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Phaser engine bundle&lt;/td&gt;
&lt;td&gt;1.13 MB&lt;/td&gt;
&lt;td&gt;0.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sound effects and music&lt;/td&gt;
&lt;td&gt;9.1 MB&lt;/td&gt;
&lt;td&gt;4.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Art&lt;/strong&gt; (PNG backgrounds, sprite sheets, props)&lt;/td&gt;
&lt;td&gt;~193 MB&lt;/td&gt;
&lt;td&gt;~95%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the first row again. &lt;strong&gt;The entire game — every scene, every drag interaction, every line of logic — is 38 KB.&lt;/strong&gt; The art in front of it is roughly five thousand times larger. That ratio is the whole story, and it is not unusual. It is what happens when art is exported at source resolution and dropped straight into an assets folder, which is the default behaviour of every art pipeline unless someone deliberately intervenes.&lt;/p&gt;

&lt;p&gt;The individual files show where it went:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Dimensions&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;The problem&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;grandma.png&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3375×3375 RGBA&lt;/td&gt;
&lt;td&gt;6.92 MB&lt;/td&gt;
&lt;td&gt;A 9-frame sprite sheet at 1125×1125 per frame&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;grandma-blinking.png&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3375×3375 RGBA&lt;/td&gt;
&lt;td&gt;6.15 MB&lt;/td&gt;
&lt;td&gt;A second sheet of the same character&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bg.png&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2688×1792 RGB&lt;/td&gt;
&lt;td&gt;6.18 MB&lt;/td&gt;
&lt;td&gt;A photographic background stored losslessly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;level-1-bg.png&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2688×1792 RGB&lt;/td&gt;
&lt;td&gt;5.33 MB&lt;/td&gt;
&lt;td&gt;Same again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bgm.mp3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;4.91 MB&lt;/td&gt;
&lt;td&gt;Full-length music loaded up front&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things are worth pulling out of that table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The backgrounds are bigger than the screen they are drawn on.&lt;/strong&gt; They are 2688×1792. The game canvas is 1920×1280, and on a normal desktop browser window it is displayed at around 1094×730. So roughly &lt;em&gt;five times&lt;/em&gt; more pixels are being downloaded than are ever shown. Nobody decided this; it is just the size the file came out of the art tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PNG is the wrong format for most of this.&lt;/strong&gt; PNG is lossless, which is exactly right for crisp pixel art at its native size and exactly wrong for a 2688×1792 painted background. Those two backgrounds have no transparency at all — they are stored as RGB — so nothing is gained by keeping them lossless.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit that actually causes the black screen
&lt;/h2&gt;

&lt;p&gt;203 MB is bad, but on its own it would not produce a blank screen, because a well-built game only loads what the current scene needs. The black screen comes from &lt;em&gt;where&lt;/em&gt; the bytes sit.&lt;/p&gt;

&lt;p&gt;Pastanation's preloader queues eleven things before it hands over to the menu. Nine of them are small. Three are not:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Queued in the preloader&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;grandma.png&lt;/code&gt; sprite sheet&lt;/td&gt;
&lt;td&gt;6.92 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;grandma-blinking.png&lt;/code&gt; sprite sheet&lt;/td&gt;
&lt;td&gt;6.15 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;bgm.mp3&lt;/code&gt; background music&lt;/td&gt;
&lt;td&gt;4.91 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eight other sounds and icons&lt;/td&gt;
&lt;td&gt;~4 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total before the first frame&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20.9 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That 20.9 MB has to arrive &lt;em&gt;in full&lt;/em&gt; before the loader's completion callback runs and the first scene draws anything. On a 25 Mbps connection that is about &lt;strong&gt;7 seconds&lt;/strong&gt;; on a 10 Mbps connection, about &lt;strong&gt;18 seconds&lt;/strong&gt;; on a mid-range mobile connection, considerably longer. (That's just arithmetic on the byte count — real-world times are worse once you add decode and decompression.)&lt;/p&gt;

&lt;p&gt;And here is the part that turns a slow load into a lost player. The preloader contains &lt;strong&gt;no progress handler at all&lt;/strong&gt; — nothing hooked to the loader's &lt;code&gt;progress&lt;/code&gt; event, no bar, no spinner, no percentage, not even a "Loading…" label. There is nothing to draw the loading UI &lt;em&gt;with&lt;/em&gt;, because the assets that would draw it are stuck in the same queue as everything else.&lt;/p&gt;

&lt;p&gt;So the player gets the worst possible combination: a long wait, and no evidence that anything is happening.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The general rule.&lt;/strong&gt; Your loading screen must be built from assets that are already loaded. If your progress bar is waiting in the same queue it is meant to be reporting on, you do not have a progress bar — you have a black screen with good intentions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The one that catches people on mobile: texture memory
&lt;/h2&gt;

&lt;p&gt;File size is the number everyone looks at. It is not the number that crashes phones.&lt;/p&gt;

&lt;p&gt;Once an image is decoded and uploaded to the GPU, an uncompressed RGBA texture occupies &lt;strong&gt;width × height × 4 bytes&lt;/strong&gt; of video memory, no matter how well it compressed on disk. So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3375 × 3375 × 4 bytes = 45,562,500 bytes ≈ 43 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 6.92 MB PNG becomes roughly &lt;strong&gt;43 MB of texture memory&lt;/strong&gt;. The second sprite sheet is another 43 MB. Before the menu has drawn a single pixel, the game is asking for something in the region of 86 MB of GPU memory for two pictures of the same character.&lt;/p&gt;

&lt;p&gt;On a desktop this is merely wasteful. On a budget phone it is how you get a silent tab crash that never appears in anyone's error log, because the browser kills the page rather than reporting an exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check your own game in ten minutes
&lt;/h2&gt;

&lt;p&gt;None of this needs special tooling. Six steps, in order of how much they'll tell you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure the folder.&lt;/strong&gt; Before anything clever, just look at the total size of your build directory. &lt;code&gt;du -sh build/&lt;/code&gt; on macOS or Linux; right-click → Properties on Windows. If that number surprises you, stop and read it properly. Ours did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sort your assets by size, largest first.&lt;/strong&gt; Almost always, a handful of files are most of the problem. &lt;code&gt;find . -type f -printf "%s\t%p\n" | sort -nr | head -20&lt;/code&gt; is enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throttle your network and watch it like a stranger.&lt;/strong&gt; Open DevTools → Network, set throttling to "Fast 3G", hard-reload, and &lt;em&gt;do not touch anything&lt;/em&gt;. Watch the clock. Whatever you see in the first ten seconds is what a real player's first impression is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find the number that matters:&lt;/strong&gt; total transferred before your first frame renders. That is the only load figure worth optimising against.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check your preloader draws something immediately.&lt;/strong&gt; Does a bar appear in the first second? If not, that is your highest-value fix, and it is usually an hour's work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open it on a real phone&lt;/strong&gt;, on mobile data, not on your desk wifi. This is the step everyone skips and the one that finds the most.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What actually fixes it
&lt;/h2&gt;

&lt;p&gt;In rough order of effort-to-payoff:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Show a loading bar, built from already-loaded assets
&lt;/h3&gt;

&lt;p&gt;Cheapest fix, biggest perceived improvement. Have a tiny first scene that loads one small logo, draws the loading UI, and only then queues the real assets while hooking the loader's progress event. Every major framework exposes this — in Phaser it's &lt;code&gt;this.load.on('progress', ...)&lt;/code&gt;. A moving bar buys you an enormous amount of patience.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Resize art to the size it is actually displayed at
&lt;/h3&gt;

&lt;p&gt;Free, and often the single biggest win. If your canvas is 1920×1280, a 2688×1792 background is pure waste. Export at the size you draw it, allowing a sensible margin for high-DPI screens — not five times over.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Stop shipping painted artwork as PNG
&lt;/h3&gt;

&lt;p&gt;Keep PNG for crisp pixel art at native resolution. For painted or photographic backgrounds, WebP or AVIF will typically be a fraction of the size at a quality difference nobody can see in a moving game. Keep transparency where you genuinely need it — RGBA costs a third more memory than RGB, and plenty of "transparent" assets don't have a transparent pixel in them.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Load per scene, not all at once
&lt;/h3&gt;

&lt;p&gt;The menu does not need the level's artwork. Load what the current scene needs, and fetch the next scene's assets while the player is reading the menu. This alone can take a first-load figure from tens of megabytes to a couple.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Pack sprites into an atlas
&lt;/h3&gt;

&lt;p&gt;A grid sprite sheet exported at source resolution wastes both bytes and texture memory on empty space. A proper texture atlas packs frames tightly and ships a small JSON map alongside.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Stream long audio, don't preload it
&lt;/h3&gt;

&lt;p&gt;A five-megabyte music loop does not need to be fully downloaded before the title screen appears. Load the short effects up front and stream the music.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other way a game looks fine and isn't
&lt;/h2&gt;

&lt;p&gt;Since we're being straight about our own site: there is a second failure on SlowDen right now, and it has the same shape — everything returns 200 and nothing works.&lt;/p&gt;

&lt;p&gt;Most of the games listed on SlowDen are third-party titles embedded from an external provider. As of &lt;strong&gt;31 July 2026&lt;/strong&gt; those embeds are being refused at the provider's end because slowden.com isn't registered with them as a publisher domain, so the embed returns a block-and-redirect page instead of a game. Every URL involved still answers &lt;code&gt;200&lt;/code&gt;. A link checker sees a healthy site. A player sees a message saying the game is not available here.&lt;/p&gt;

&lt;p&gt;We're working on it, and we're saying so here rather than letting the blog imply a catalogue that plays. The reason it belongs in this article is the lesson it repeats: &lt;strong&gt;the only test that counts is loading the live page yourself, on the real domain, and pressing play.&lt;/strong&gt; Not a status code. Not a link checker. Not your local build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  My browser game shows a black screen but there are no console errors. What is wrong?
&lt;/h3&gt;

&lt;p&gt;A black screen with no errors almost always means the game is still loading, not that it crashed. Most web game frameworks render nothing until their preload queue finishes, so if that queue is large the player sits in front of a blank canvas. Check the Network tab for the total transferred size before the first frame appears. If it is tens of megabytes, you have a payload problem, not a code problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  How big should a browser game be?
&lt;/h3&gt;

&lt;p&gt;There is no official limit, but the practical target most web developers work to is that something meaningful should be on screen within a few seconds on an average connection. That usually means getting your first-render payload into the low single-digit megabytes and streaming the rest afterwards. The exact number matters far less than the principle: never make the player wait in front of nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my PNG so large?
&lt;/h3&gt;

&lt;p&gt;PNG is lossless, so it stores every pixel exactly. A 3375×3375 RGBA sprite sheet is over 11 million pixels at four bytes each before compression, and photographic or painterly artwork compresses poorly in PNG. Backgrounds with no transparency are usually far smaller as WebP or AVIF, and sprite sheets with transparency are usually far smaller as WebP.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a large texture use more memory than its file size?
&lt;/h3&gt;

&lt;p&gt;Yes, and this catches people out. Once decoded and uploaded to the GPU, an uncompressed RGBA texture occupies width × height × 4 bytes regardless of how well the file compressed on disk. A 3375×3375 sheet is about 43 MB of texture memory even if the PNG on disk is 7 MB. This is a common cause of crashes on phones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I show a loading bar?
&lt;/h3&gt;

&lt;p&gt;Yes, and it should appear before the heavy assets are queued, not after. Load one tiny image in a first scene, draw your loading UI from it, then start the real queue and hook your loader's progress event. A player who can see a bar moving will wait. A player looking at a black rectangle assumes the game is broken and leaves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where these numbers came from
&lt;/h2&gt;

&lt;p&gt;So you can check them rather than take our word for it. All measured on &lt;strong&gt;31 July 2026&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Total size and per-file sizes&lt;/strong&gt; were measured on the deployed copy of the game and confirmed against the live server with HTTP range requests — for example &lt;code&gt;grandma.png&lt;/code&gt; reports &lt;code&gt;Content-Range: bytes 0-0/7259947&lt;/code&gt;, i.e. 7,259,947 bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image dimensions&lt;/strong&gt; were read from each PNG's IHDR header, which also gives the colour type used to distinguish RGB from RGBA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The preload contents&lt;/strong&gt; were read from the game's own compiled source: eleven queued assets totalling 21,960,586 bytes, with no &lt;code&gt;progress&lt;/code&gt; handler present.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The console and network state&lt;/strong&gt; were observed by loading the live page in a real browser. The estimated wait times are arithmetic on the byte count at the stated connection speeds, not measured timings — treat them as a floor, not a prediction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pastanation is a SlowDen Original&lt;/strong&gt; — a game built by SlowDen. Most games listed elsewhere on SlowDen are third-party or partner titles and are not SlowDen's work.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you have a browser game sitting in a folder
&lt;/h2&gt;

&lt;p&gt;Measure it before you show it to anyone. Ten minutes with the Network tab will tell you more about whether strangers will play your game than another week of features will.&lt;/p&gt;

&lt;p&gt;And when it loads the way you want it to, it deserves to exist somewhere other than your hard drive.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://slowden.com/blog/where-to-publish-html5-browser-game-free-2026/" rel="noopener noreferrer"&gt;Where to publish free in 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://slowden.com/submit/" rel="noopener noreferrer"&gt;Submit your game to SlowDen&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://slowden.com/slow-cook/" rel="noopener noreferrer"&gt;See Slow Cook&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on the SlowDen blog: &lt;a href="https://slowden.com/blog/why-your-browser-game-loads-to-a-black-screen/" rel="noopener noreferrer"&gt;Why your browser game loads to a black screen&lt;/a&gt;. All measurements were taken on 31 July 2026 and describe the game as it stood on that date; we intend to fix what is described here, so re-measure before relying on any figure. Phaser is a third-party open-source framework and is named for accuracy only, implying no affiliation or endorsement.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>gamedev</category>
      <category>performance</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I'm 11 Years Old. Last Week I Launched My First SaaS. Here's What Actually Happened.</title>
      <dc:creator>Liza Bhadana</dc:creator>
      <pubDate>Tue, 26 May 2026 08:06:42 +0000</pubDate>
      <link>https://dev.to/liza_18827c15bdd387c4cd9c/im-11-years-old-last-week-i-launched-my-first-saas-heres-what-actually-happened-lgi</link>
      <guid>https://dev.to/liza_18827c15bdd387c4cd9c/im-11-years-old-last-week-i-launched-my-first-saas-heres-what-actually-happened-lgi</guid>
      <description>&lt;p&gt;&lt;strong&gt;Try Aries AI: &lt;a href="https://aries-a.netlify.app" rel="noopener noreferrer"&gt;https://aries-a.netlify.app&lt;/a&gt;&lt;/strong&gt; (currently desktop browser only)&lt;/p&gt;

&lt;p&gt;A week ago I clicked "Publish" on a project called &lt;strong&gt;Aries AI&lt;/strong&gt; — a free AI abacus tutor I'd been building solo from my home in India. I'm in middle school. I taught myself to code. I am eleven years old.&lt;/p&gt;

&lt;p&gt;This is what happened next, what I learned, and what I'm doing now. No filter, no startup-bro vibes. Just what really happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Aries AI actually is
&lt;/h2&gt;

&lt;p&gt;For anyone new here: Aries AI is a browser-based AI tutor that teaches kids (and adults) mental arithmetic through the abacus method. It has four modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;interactive visual abacus&lt;/strong&gt; you click and drag&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oral practice&lt;/strong&gt; where the app speaks number sequences out loud and you keep the running total in your head&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Formula lessons&lt;/strong&gt; covering the three families I learned in my own abacus classes: Big Friends formulas, Small Friends formulas, and Combination formulas&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;conversational AI tutor&lt;/strong&gt; (powered by OpenAI) that re-explains anything if it doesn't click the first time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing runs on OpenAI + Supabase + Render + Razorpay + Netlify. Built solo.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the launch actually went
&lt;/h2&gt;

&lt;p&gt;I posted across about ten platforms in two days — Medium, Dev.to, Hashnode, LinkedIn, Substack, Tumblr, Pinterest, Bluesky, X, and Reddit. I also got the app listed on AlternativeTo and set up a Product Hunt page.&lt;/p&gt;

&lt;p&gt;Here's what I learned:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-posting to ten platforms at once is real work.&lt;/strong&gt; It took me about six hours total, including editing platform-specific versions. People who say "just post everywhere" don't realize how different the platforms are. Dev.to wants the build story. Pinterest wants a visual pin. Reddit will silently ban you if you sound too promotional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The most engagement came from places I didn't expect.&lt;/strong&gt; Bluesky and Tumblr — the platforms I almost skipped — got more genuine replies than X or LinkedIn in the first 24 hours. People on the smaller platforms are more likely to actually try your thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I shipped a real bug into production.&lt;/strong&gt; I told everyone the app was "browser-based, no install needed" without realizing how much of my audience would try it on mobile. The app isn't mobile-ready yet. I spent the next day going back to every single post and adding a "desktop browser only" note. Lesson learned: always test on your audience's device, not just your own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My abacus teacher is going to review it.&lt;/strong&gt; That feedback is going to be more valuable than anything 1000 random users could tell me. If you're shipping something that requires expertise, getting one expert to look at it is worth a hundred casual testers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually built (in plain language)
&lt;/h2&gt;

&lt;p&gt;The boring stack story:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI&lt;/strong&gt; powers the AI tutor. The hard part wasn't the model — it was pinning my actual lesson plans into the system prompt so the tutor doesn't drift to generic abacus answers from its training data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; handles users, login, and the database. Row-level security took me an evening to figure out, but once it was set, I stopped worrying about auth completely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render&lt;/strong&gt; runs the backend. Not glamorous. It just works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Razorpay&lt;/strong&gt; for payments because I live in India and UPI is how most users want to pay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Netlify&lt;/strong&gt; for the frontend. Zero-config deploys are a gift.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The non-boring story: every piece of this stack has a generous free tier. I spent under ₹1000 in actual infrastructure costs across multiple months. The hard part wasn't the money. The hard part was sitting alone in front of bugs at 11pm and refusing to give up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I wish someone told me before launch day
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reply to every single comment in the first 48 hours.&lt;/strong&gt; Reddit's algorithm decides whether your post lives or dies based on early comment activity. I went from a ranked launch post to a buried one because I missed the first few hours.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Your launch is not your launch.&lt;/strong&gt; The day you click "publish" is the start of a week-long process of replying, fixing, replying again. Block out the time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;People love founders, not products, at this stage.&lt;/strong&gt; When I added "I'm 11 years old, building from India" to my posts, engagement easily tripled. Not because the product changed — because the story did.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Friends who say they'll share won't. Strangers who say nothing will.&lt;/strong&gt; It's fine.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Save every kind word someone says.&lt;/strong&gt; Three days in, I screenshot every nice comment and put them in a folder called &lt;code&gt;proof&lt;/code&gt;. When I'm staring at a bug at midnight, that folder is the thing that keeps me going.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I'm working on this week
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mobile support.&lt;/strong&gt; The single most common feedback was "I tried it on my phone and nothing happened." Building a mobile-friendly version is the top priority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better onboarding for the formula lessons.&lt;/strong&gt; A few people got lost between Big Friends and Small Friends. I'm rewriting the intro.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Getting feedback from my actual abacus teacher.&lt;/strong&gt; Easily the highest-leverage thing I can do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Launching my personal site&lt;/strong&gt; at the kimstudio brand — a portfolio with my projects, games I built, and other things I'm working on.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you're a kid reading this
&lt;/h2&gt;

&lt;p&gt;If you're 9, 10, 11, 12, 13 — and you're thinking about building something:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start the day you have the idea, even if you don't know how.&lt;/li&gt;
&lt;li&gt;The first thing you ship will be embarrassing. Ship it anyway.&lt;/li&gt;
&lt;li&gt;The internet will be kinder than you expect, and crueler in specific ways. Both will happen the first week.&lt;/li&gt;
&lt;li&gt;The "real" developers are not gatekeeping. They were once where you are. Ask them things directly.&lt;/li&gt;
&lt;li&gt;Your age is your superpower, not a weakness. Lead with it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  If you're an adult reading this
&lt;/h2&gt;

&lt;p&gt;Try &lt;a href="https://aries-a.netlify.app" rel="noopener noreferrer"&gt;Aries AI&lt;/a&gt;. If you have kids learning the abacus, send them. If you're learning mental math yourself, try the oral practice mode for 15 minutes — it's the closest thing to having a real teacher read numbers to you.&lt;/p&gt;

&lt;p&gt;If you teach abacus, mental math, or any math-adjacent subject — please tell me what's wrong with the formula sequencing. I want to learn.&lt;/p&gt;

&lt;p&gt;If you've shipped something solo — what did you wish someone told you in week one?&lt;/p&gt;




&lt;p&gt;Thanks for reading. Sending this from India.&lt;/p&gt;

&lt;p&gt;— Liza, 11&lt;/p&gt;

&lt;p&gt;✨ Live app: &lt;a href="https://aries-a.netlify.app" rel="noopener noreferrer"&gt;https://aries-a.netlify.app&lt;/a&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Building Aries AI: A Solo-Built AI Abacus Tutor on OpenAI + Supabase + Render + Razorpay</title>
      <dc:creator>Liza Bhadana</dc:creator>
      <pubDate>Sun, 24 May 2026 17:08:19 +0000</pubDate>
      <link>https://dev.to/liza_18827c15bdd387c4cd9c/building-aries-ai-a-solo-built-ai-abacus-tutor-on-openai-supabase-render-razorpay-5bp3</link>
      <guid>https://dev.to/liza_18827c15bdd387c4cd9c/building-aries-ai-a-solo-built-ai-abacus-tutor-on-openai-supabase-render-razorpay-5bp3</guid>
      <description>&lt;p&gt;&lt;strong&gt;Live: &lt;a href="https://aries-a.netlify.app" rel="noopener noreferrer"&gt;https://aries-a.netlify.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Heads up:&lt;/strong&gt; Aries AI currently runs only in a desktop or laptop browser. Mobile/phone support is not yet available.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is a short build-in-public post about &lt;strong&gt;Aries AI&lt;/strong&gt;, an AI abacus tutor I shipped solo. Stack notes, what worked, what didn't, and a link if you want to poke at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The product, in one paragraph
&lt;/h2&gt;

&lt;p&gt;Aries AI teaches mental arithmetic through the abacus method. It has four modes: an interactive visual abacus, oral drills (TTS reads a sequence of numbers, user types the running total), structured formula lessons (three families of abacus shortcuts — Big Friends, Small Friends, and Combination formulas), and a conversational AI tutor that re-explains and generates custom practice. Free tier + paid plans.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Frontend  -&amp;gt; Netlify (static deploy, free tier)
AI model  -&amp;gt; OpenAI API
Backend   -&amp;gt; Render
Database  -&amp;gt; Supabase (Postgres + Auth + RLS)
Payments  -&amp;gt; Razorpay
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Honest take on each piece:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Netlify&lt;/strong&gt; for the frontend was the obvious call. Free tier is generous, CI from git, instant rollbacks. Deploy times under a minute. The only friction I hit was function cold starts when I briefly tried to use Netlify Functions for one endpoint — moved that to Render instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenAI&lt;/strong&gt; powers the tutor. The interesting work here was not the model — it was the prompt scaffolding to keep the tutor consistent with the in-app curriculum. The Big/Small/Combination formula explanations had to be pinned in the system prompt so the model would not drift to generic abacus explanations from its training data, which often contradict the specific mnemonics I teach with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Render&lt;/strong&gt; for the backend was a quality-of-life upgrade over trying to glue serverless functions together. One service, one deploy, sane logs. Free tier sleeps after inactivity, which is a known tax but fine for the current traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase&lt;/strong&gt; carried more weight than anything else in the stack. Postgres + auth + row-level security + a usable dashboard, all in one place, with a generous free tier. The RLS policies took a couple of hours to get right but once they were in place, I stopped worrying about auth almost entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Razorpay&lt;/strong&gt; for payments — non-negotiable if you are targeting Indian users, because UPI is how most people actually want to pay. International cards work too, so I get global users without having to plug in a second payment provider.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build oral practice earlier.&lt;/strong&gt; It is the feature users come back for, and I almost cut it from v1 to ship faster. Mistake.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the AI tutor's curriculum harder from day one.&lt;/strong&gt; Early users got generic abacus explanations from the model that contradicted my lessons. Took two iterations of the system prompt to fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set up an analytics event for "user finishes a formula lesson"&lt;/strong&gt; from day one, not week three. Without it I had no idea which formula was the drop-off point.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Netlify + Supabase + Render is a genuinely cheap stack to run a small EdTech product on. Monthly infrastructure cost is still essentially zero at current usage.&lt;/li&gt;
&lt;li&gt;A single, focused AI tutor inside a structured curriculum performs &lt;em&gt;much&lt;/em&gt; better than dropping users into an open-ended chat. The structure does most of the teaching; the AI handles the long tail of "wait, I don't get this part."&lt;/li&gt;
&lt;li&gt;Razorpay's checkout works on the first try, which is rare for payment integrations.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://aries-a.netlify.app" rel="noopener noreferrer"&gt;&lt;strong&gt;aries-a.netlify.app&lt;/strong&gt;&lt;/a&gt; — happy to take feedback, especially from anyone who has shipped EdTech before, or anyone with strong opinions about OpenAI prompt design for tutoring.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
