<?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: Denshin Team</title>
    <description>The latest articles on DEV Community by Denshin Team (@denshin).</description>
    <link>https://dev.to/denshin</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%2F4136368%2F60ba9ad4-9a65-4aa7-8a56-a1a72d063e75.png</url>
      <title>DEV Community: Denshin Team</title>
      <link>https://dev.to/denshin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/denshin"/>
    <language>en</language>
    <item>
      <title>How We Took a PixiJS Map From 33.9 s to 3.0 s on a Slow Phone</title>
      <dc:creator>Denshin Team</dc:creator>
      <pubDate>Thu, 24 Sep 2026 16:20:04 +0000</pubDate>
      <link>https://dev.to/denshin/how-we-took-a-pixijs-map-from-339-s-to-30-s-on-a-slow-phone-4adg</link>
      <guid>https://dev.to/denshin/how-we-took-a-pixijs-map-from-339-s-to-30-s-on-a-slow-phone-4adg</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.denshin.io/pixijs-map-loading-on-slow-phones" rel="noopener noreferrer"&gt;blog.denshin.io&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Our team builds DENSHIN, a game whose main screen is a live city map drawn with PixiJS v8: a 10 × 10 grid of city blocks, road markings, traffic, planes and other players moving in real time. Players complete on-chain quests to open parts of the city, and none of that matters for what follows.&lt;/p&gt;

&lt;p&gt;On 29 July we loaded a production build with the network throttled to 1.6 Mbps and the CPU slowed 6x. The loading overlay said &lt;strong&gt;RENDERING MAP&lt;/strong&gt; for 33.9 seconds. By the evening it said so for 3.0 seconds, with the same city behind it. This post covers what we moved, in what order, and the phone crashes from earlier in the summer that shaped the loader.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Production build, 1.6 Mbps, CPU 6x&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"RENDERING MAP" on screen&lt;/td&gt;
&lt;td&gt;33.9 s&lt;/td&gt;
&lt;td&gt;3.0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total time until the map is up&lt;/td&gt;
&lt;td&gt;38.7 s&lt;/td&gt;
&lt;td&gt;7.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bytes before the first frame&lt;/td&gt;
&lt;td&gt;6.9 MB&lt;/td&gt;
&lt;td&gt;1.0 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The first frame waited for 196 MB of pictures
&lt;/h2&gt;

&lt;p&gt;Earlier that day, against production, the map took 35.7 s to appear and pulled 203.7 MB in 309 requests. 196.1 MB of it was 62 PNGs: the art painted into each block. All 62 sat inside the &lt;code&gt;Assets.load()&lt;/code&gt; that &lt;code&gt;GameApp.init()&lt;/code&gt; awaits before the overlay lifts. On home Wi-Fi that was about a minute.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encode for the size you draw.&lt;/strong&gt; A block is 18 grid cells of 64 px, so 1152 world pixels, and the camera never zooms past 1:1. The masters were PNGs of up to 1254 px, and not one pixel in any of them was transparent. Re-encoded as WebP, capped at 1152 px and with no alpha channel, the set is 16.6 MB, 11.8x smaller. Phones get a 512 px set of 4.6 MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Take them out of the awaited list.&lt;/strong&gt; The category is registered with &lt;code&gt;Assets&lt;/code&gt; but not enrolled in the bulk load:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// null: registered, but not in the list loadAll() awaits&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerGlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;lowPower&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;buildingMobileUrlMap&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;buildingUrlMap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ASSET_CATEGORY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BUILDING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;lowPower&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;autoGenerateMipmaps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every block is now drawn at once from one shared 494-byte blurred placeholder, and the real images stream in over it. The first frame dropped to 7.4 MB and 223 requests, with no block art at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the awaited list was decoration too
&lt;/h2&gt;

&lt;p&gt;On a 1.6 Mbps link, the 6.9 MB still behind the overlay is about 34 seconds of pure transfer. The render pipeline took 410 ms of the total, even at 6x CPU. We were bandwidth-bound, so the fix was to ask for fewer bytes, not to render faster.&lt;/p&gt;

&lt;p&gt;Here is what the first frame was waiting for:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Needed at first paint?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Road markings and overlays&lt;/td&gt;
&lt;td&gt;324 KB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A rectangular block set&lt;/td&gt;
&lt;td&gt;984 KB&lt;/td&gt;
&lt;td&gt;No. Its only renderer was switched off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cracks, stains, litter&lt;/td&gt;
&lt;td&gt;2.7 MB&lt;/td&gt;
&lt;td&gt;No. Phones never draw them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cars, planes, manhole covers&lt;/td&gt;
&lt;td&gt;2.4 MB&lt;/td&gt;
&lt;td&gt;Can arrive a second later&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows need a note. Phones downloaded 2.7 MB of debris for renderers that are disabled on phones. And the rectangular set was pulled in by a glob for a code path behind a disabled flag. That one is a Vite trap: an eager &lt;code&gt;import.meta.glob&lt;/code&gt; is rewritten into real imports before dead-code elimination runs, so its files ship whether or not anything can reach them. The same pattern had put 337 files, 70 MB, of another unused block set into &lt;code&gt;dist&lt;/code&gt;. A build flag can't fix that. The glob has to go.&lt;/p&gt;

&lt;p&gt;The manhole covers were a smaller case of the same thing: 1.32 MB of roughly 1000 px art for sprites drawn at 6–9 px. We deleted them.&lt;/p&gt;

&lt;p&gt;The awaited list is now two placeholders plus the markings and road overlays, 324 KB in total. That's what took RENDERING MAP from 33.9 s to 3.0 s.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four waves that never overlap
&lt;/h2&gt;

&lt;p&gt;Moving everything to "later" created a new problem: everything arrived later at the same time. The block art (about 17 MB on desktop, 4.6 MB on a phone) started downloading together with 1.1 MB of cars and planes and took their bandwidth. At 1.6 Mbps, traffic finished at 21.8 s. Until then players looked at empty streets and blurred blocks, and they read the blur as locked content.&lt;/p&gt;

&lt;p&gt;Now the boot runs in four waves, and each one starts only after the previous one has landed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. loadAll()         324 KB  -&amp;gt; the city is drawn
2. loadDeferred()    1.1 MB  -&amp;gt; cars, planes, players
3. startStreaming()  block art, nearest first
4. loadTrim()        2.7 MB  -&amp;gt; asphalt debris (desktop)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the same throttling, traffic now finishes at 11.7 s instead of 21.8 s, and the first wave is untouched. The chain lives in one method, and &lt;code&gt;init()&lt;/code&gt; does not await it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;populate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RenderPipeline&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;populated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;AssetLoader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadDeferred&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;alive&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;late&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderDeferredLayers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
          &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trafficSystem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;late&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trafficSystem&lt;/span&gt;
          &lt;span class="c1"&gt;// ...quests, other players, planes&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;streamArt&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in there each came from a bug:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two animation frames, not one.&lt;/strong&gt; One frame only queues the paint. The second makes sure the city was actually presented before the main thread gets busy again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;alive()&lt;/code&gt; checks.&lt;/strong&gt; A wave can land after the player has left the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 45-second deadline on the art wave.&lt;/strong&gt; A block that never arrives must not hold back the wave behind it. Failures are swallowed, because every wave only adds to the city. A city without planes still works.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Nearest the player first
&lt;/h2&gt;

&lt;p&gt;The streamer used to walk blocks in city order. The top-left corner sharpened first while the block under the player stayed a blur. Now every scan (one per 140 ms) sorts pending blocks by distance to the centre of the screen, which is where the opening camera puts the player:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pending&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;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&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;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;viewportOnly&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;isInViewport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;distanceSq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;centre&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;distanceSq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;centre&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sorting about a hundred items that often costs nothing next to one texture upload, and after a pan it follows the camera without any extra bookkeeping. At most 4 loads run at once on desktop and 2 on phones, because more than that makes decode and upload stutter the frame. After 2.5 s the viewport filter drops and the rest of the city fills in, outward from the centre.&lt;/p&gt;

&lt;h2&gt;
  
  
  Crash: &lt;code&gt;Assets.unload&lt;/code&gt; and the null &lt;code&gt;alphaMode&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;That streamer only loads. It never unloads, and that's a deliberate rule.&lt;/p&gt;

&lt;p&gt;In June an earlier block streamer freed GPU memory by calling &lt;code&gt;Assets.unload()&lt;/code&gt; on blocks that left the screen. &lt;code&gt;unload&lt;/code&gt; destroys the texture's GPU source. When the player panned back, &lt;code&gt;Assets.load()&lt;/code&gt; for the same alias handed back the same cached texture object, which now had no source. A live sprite still held it, and the batcher crashed with &lt;code&gt;Cannot read properties of null (reading 'alphaMode')&lt;/code&gt;. It never reproduced on localhost, because it needed real latency and memory pressure. Running load and unload one at a time didn't help: the cache reuse itself was the trap.&lt;/p&gt;

&lt;p&gt;We removed eviction entirely. Memory is now bounded by texture size and count: one district, a fixed set of images, and smaller files on phones. The loader also refuses to hand out a dead texture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;texture&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;source&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;source&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destroyed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Crash: a world-sized RenderTexture is 0.8 GB
&lt;/h2&gt;

&lt;p&gt;That same June day, phones killed the tab right after RENDERING MAP. There was no JavaScript error, just iOS WebKit running out of memory. Three decorative renderers (neon reflections, road debris, neon glow) each baked into a RenderTexture the size of the whole world, about 14848 × 14848 px. At 4 bytes a pixel that's roughly 0.8 GB of GPU memory each, and about 2.5 GB for all three. A desktop GPU absorbs that. A phone can't.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Each bakes a world-sized (~14848²) RenderTexture, ~0.8 GB apiece.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heavyEffects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isLowPowerDevice&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;heavyEffects&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderNeonReflections&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderNeonGlow&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;On phones the block surface that desktop bakes is drawn as live masked sprites instead. Our rule now: before any render target ships, multiply width × height × 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  The device tier answers a memory question, nothing else
&lt;/h2&gt;

&lt;p&gt;The tier check itself is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isLowPowerDevice&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Navigator&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;deviceMemory&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&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;deviceMemory&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&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;deviceMemory&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;coarse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(pointer: coarse)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;minSide&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&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="nx"&gt;screen&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mem&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;mem&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;4&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;coarse&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;minSide&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;820&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;Every phone trips it, fast ones included. That's fine for what it decides: 512 px block art instead of 1152 px, no mipmaps, a lower zoom cap to match, and no world-sized bakes.&lt;/p&gt;

&lt;p&gt;Twice we used it for more than that. First we turned headlight beams off on phones, and later the whole locked-block treatment (haze and blur). Both times the phone quietly lost something the desktop had, and both times it was reported as a bug. Now the tier can make the pictures lighter, but it can't change what the map shows.&lt;/p&gt;

&lt;p&gt;Frame time is a separate budget and gets its own answer. We measured the beams under mobile emulation, interleaving the runs because the test rig drifted by up to 2x between batches. At 4x CPU the p95 frame went from 18.5 ms to about 32 ms with beams on. At 1x there was no cost at all. We kept the beams.&lt;/p&gt;

&lt;p&gt;The locked-block effects step down only when the median frame stays above 24 ms for 3 seconds. The first version triggered on p95 above 14 ms. A healthy session runs at p50 16.7 ms and p95 17.6 ms, so that ladder fired in every session: by second 26, only a flat fill was left. The median tells you a device can't keep up. The tail only tells you that frames sometimes spike.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eight canvases on one landing page
&lt;/h2&gt;

&lt;p&gt;Our landing page showed eight live fragments of the map, which meant eight WebGL contexts. On an emulated iPhone 13, one scroll down and back up created 51 contexts and took the JS heap to 254 MB, which is where iOS starts killing tabs. Now only the hero is live. The other seven are stills baked in a headless browser by the same renderer from the same seed. The only visible difference is that the cars have stopped. The same scroll now creates 3 contexts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bugs that looked like slow loading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A late mipmap flag.&lt;/strong&gt; We set &lt;code&gt;autoGenerateMipmaps&lt;/code&gt; on a texture after &lt;code&gt;Assets.load&lt;/code&gt; resolved, which forced a full re-upload of a live 1152² texture. That upload didn't fit in one frame, so blocks grew in from the top-left corner. Upload options now go in with the source, in &lt;code&gt;Assets.add({ alias, src, data })&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A clipped blur.&lt;/strong&gt; While panning, locked blocks drew half-empty and healed after about 10 seconds, which looked exactly like slow streaming. The real cause was a blur filter at 0.5 resolution with no &lt;code&gt;filterArea&lt;/code&gt;: PixiJS took the region from the object's bounds and cut off the bottom fifth. An explicit &lt;code&gt;filterArea&lt;/code&gt; fixed it. We found it by comparing sprite bounds in a live dev session and switching mask, overlay and blur off one at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we check now
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The awaited load holds only what the first frame can't be drawn without. Every byte added there is paid by every player on every visit.&lt;/li&gt;
&lt;li&gt;We measure on a production build with network and CPU throttled. Localhost hides both the bandwidth math and the GPU memory race.&lt;/li&gt;
&lt;li&gt;Waves are ordered by what the player notices, and they never overlap.&lt;/li&gt;
&lt;li&gt;Textures stay resident. Memory is bounded by size and count, not by eviction.&lt;/li&gt;
&lt;li&gt;Every render target gets its width × height × 4 before it ships.&lt;/li&gt;
&lt;li&gt;The device tier picks asset sizes. Measured frame time decides which effects run.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The map is live at &lt;a href="https://app.denshin.io/" rel="noopener noreferrer"&gt;app.denshin.io&lt;/a&gt; and opens for guests with no sign-in. It's a good test case for DevTools throttling.&lt;/p&gt;

</description>
      <category>pixijs</category>
      <category>webgl</category>
      <category>performance</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>We built a Web3 quest game where the progress bar is a city</title>
      <dc:creator>Denshin Team</dc:creator>
      <pubDate>Mon, 21 Sep 2026 19:49:48 +0000</pubDate>
      <link>https://dev.to/denshin/i-built-a-web3-quest-game-where-the-progress-bar-is-a-city-8ka</link>
      <guid>https://dev.to/denshin/i-built-a-web3-quest-game-where-the-progress-bar-is-a-city-8ka</guid>
      <description>&lt;p&gt;Most Web3 quest platforms look the same: a list of tasks, a checkbox per task, a points counter. You swap, you tick, you forget. We wanted progress you can actually &lt;em&gt;see&lt;/em&gt;, so in Denshin the progress bar is a city.&lt;/p&gt;

&lt;p&gt;Denshin went live on September 10 with its first season, Genesis, and launches on Product Hunt on September 22. Here is how it's built, and a few decisions that turned out to matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea in one paragraph
&lt;/h2&gt;

&lt;p&gt;The map is one district of 100 quarters, and four of them are open at the start. Each quest belongs to a quarter: complete it and the quarter opens. The Genesis season has 33 quests across Base, Soneium and Sonic, and 23 of them are verified straight from the chain: holding gas, holding a stablecoin, swapping on a named DEX, supplying to a lending market, providing liquidity. There is no token and there are no NFTs. The prize pool is $100 USDT for the top 50 of the season, small on purpose: the game has to be worth playing on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drawing the city with PixiJS
&lt;/h2&gt;

&lt;p&gt;The UI is Vue 3 and the map is PixiJS. The map is a stack of containers, bottom to top:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;baseFill → roadWear → grid → roadMarkings → roadEffects (traffic)
→ neonGlow → buildings → players → aircraft → clouds → fog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two kinds of renderers draw into it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stateless renderers&lt;/strong&gt; (road markings, wear, neon glow, buildings) create their display objects once and are done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Animated systems&lt;/strong&gt; (traffic, pedestrians, aircraft, clouds, fog) register a ticker callback and hold state. Every one of them has an &lt;code&gt;init(layer, ticker)&lt;/code&gt; / &lt;code&gt;destroy(ticker)&lt;/code&gt; pair, and forgetting the second half is the fastest way to leak.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every procedural detail, from the stains on the asphalt to which cars drive which lanes, comes from a seeded PRNG (mulberry32) with one seed per renderer. The city looks the same for every player and on every reload, which also makes visual bugs reproducible.&lt;/p&gt;

&lt;p&gt;Two performance lessons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bake on desktop, not on phones.&lt;/strong&gt; On desktop the quarter art is baked into one world-sized &lt;code&gt;RenderTexture&lt;/code&gt;, which makes panning cheap. On phones that one texture runs the GPU out of memory, so mobile keeps live sprites and a lighter 512px WebP set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atlas the small stuff.&lt;/strong&gt; Road markings and vehicles ship as committed spritesheets instead of hundreds of separate PNGs.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Verifying quests from the chain
&lt;/h2&gt;

&lt;p&gt;No screenshots, no "paste your tx hash". Every on-chain quest check asks the chain one of three questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Native balance&lt;/td&gt;
&lt;td&gt;Do you hold at least X of the gas token on chain N?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token balance&lt;/td&gt;
&lt;td&gt;Do you hold at least X of a specific ERC-20?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Swap&lt;/td&gt;
&lt;td&gt;Did you swap at least X on an approved venue since you started the quest?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three rules make this hard to game:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accepted-at block.&lt;/strong&gt; Action checks only look at blocks after the moment you pressed &lt;em&gt;Start mission&lt;/em&gt;. Otherwise "did you swap on Base" would pay for any swap in the wallet's history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One transaction, one reward, ever.&lt;/strong&gt; An accepted tx hash is consumed: stored once, behind a uniqueness constraint on the hash itself. It can't close two tasks, the same task twice, or a task on another account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The server picks the hash.&lt;/strong&gt; The hash comes from the verifier's own read of the chain, never from the browser, so a client can't nominate one it likes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Swaps are matched against a registry of known pools and routers per network, so "a swap on Kyo" means Kyo, not any contract that happens to emit a &lt;code&gt;Swap&lt;/code&gt; event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting the daily check-in on-chain
&lt;/h2&gt;

&lt;p&gt;Until this week the Daily Check was an off-chain button. Now it is a transaction: &lt;code&gt;sync()&lt;/code&gt; on a tiny contract called DenshinSync, deployed at the same address on Soneium, Base and Sonic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function sync() external returns (uint32 streak) {
    uint32 day = today(); // block.timestamp / 1 days
    Record memory record = _records[msg.sender];
    if (record.lastDay &amp;gt;= day) revert AlreadySynced(msg.sender, day);

    streak = record.lastDay + 1 == day ? record.streak + 1 : 1;
    // ...store lastDay, streak, longest, total
    emit Synced(msg.sender, day, streak, total);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It holds no funds, has no owner and no admin functions, and cannot be upgraded. The day is the block's UTC day, not the player's clock. The streak that pays points is computed off-chain from &lt;code&gt;Synced&lt;/code&gt; events, so a player can sync on Base one day and on Sonic the next without breaking it.&lt;/p&gt;

&lt;p&gt;The identical address on three chains comes from a deterministic CREATE2 deployment. A side effect we didn't expect to care about: changing even a comment in the source changes the bytecode metadata, and with it the address.&lt;/p&gt;

&lt;p&gt;For the player it costs gas only, a fraction of a cent. The contract is &lt;code&gt;0xCCFFb22048d9E1fDd04F5F3B9681cC0C95a0f654&lt;/code&gt;, source verified on each chain's explorer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client:&lt;/strong&gt; Vue 3, PixiJS, viem/wagmi, Reown AppKit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server:&lt;/strong&gt; NestJS, PostgreSQL, Redis, BullMQ&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contract:&lt;/strong&gt; Solidity, Hardhat&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The map and the quests are visible without a wallet: &lt;a href="https://app.denshin.io" rel="noopener noreferrer"&gt;https://app.denshin.io&lt;/a&gt;. Docs: &lt;a href="https://docs.denshin.io" rel="noopener noreferrer"&gt;https://docs.denshin.io&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;We launch on Product Hunt on September 22. If you have thoughts on the map, the verification rules or the on-chain check-in, that is the best place to leave them: &lt;a href="https://www.producthunt.com/products/denshin" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/denshin&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We're a small team, so honest feedback is the most useful thing you can give us.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>web3</category>
      <category>gamedev</category>
      <category>blockchain</category>
    </item>
  </channel>
</rss>
