<?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>Building a Neon City Map in PixiJS: Layers, Fog and Traffic</title>
      <dc:creator>Denshin Team</dc:creator>
      <pubDate>Wed, 04 Nov 2026 05:55:21 +0000</pubDate>
      <link>https://dev.to/denshin/building-a-neon-city-map-in-pixijs-layers-fog-and-traffic-d4c</link>
      <guid>https://dev.to/denshin/building-a-neon-city-map-in-pixijs-layers-fog-and-traffic-d4c</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.denshin.io/building-a-neon-city-map-in-pixijs" rel="noopener noreferrer"&gt;the DENSHIN blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A street name belongs on the asphalt. Cars should drive over it. A quest marker needs to stay readable while the street underneath it is sealed. Those are small visual decisions, but they determine whether a player can understand a city at a glance.&lt;/p&gt;

&lt;p&gt;Our map runs in PixiJS in the browser. It combines illustrated quarter art, road geometry, moving traffic, atmosphere and clickable markers. Each piece has its own job, and the order in which we draw those pieces is part of the game design. This is a tour of that order, from the street surface to the camera looking down on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a world that moves together
&lt;/h2&gt;

&lt;p&gt;The map's layers sit inside one world container. Panning changes the world's position; zooming changes its scale. Roads, blocks and cars therefore stay in the same coordinate system while the view moves around them.&lt;/p&gt;

&lt;p&gt;That sounds ordinary until a visual element ends up outside the arrangement. A label that moves differently from the road looks attached to the screen. A glow that loses its relationship to a building looks like a floating effect. Keeping the city together gives us a stable place to decide what each object should cover.&lt;/p&gt;

&lt;p&gt;The interface has a different job. A button needs to remain usable as the player drags the map underneath it. We keep that distinction clear: the world is the place being explored, and the interface is how the player chooses what to do there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draw the street before its movement
&lt;/h2&gt;

&lt;p&gt;We begin with the ground and the road markings. Quarter surfaces and their artwork establish the blocks. Street labels sit above the surfaces, followed by road effects and traffic. Neon light, building elements and the elevated expressways complete the visible city below the lock treatment.&lt;/p&gt;

&lt;p&gt;The street labels are a useful example. We place them below traffic so a car passes over a painted name. Moving those names higher would make them easier to read in isolation, but it would also make them float above every car. Their position in the stack gives the viewer a clue about what they are.&lt;/p&gt;

&lt;p&gt;The same reasoning applies to light. A road marking needs to feel like paint under the city's night lighting. Its colour belongs to the scene around it. When every bright surface competes equally for attention, it becomes harder to distinguish a street detail from something the player can click.&lt;/p&gt;

&lt;h2&gt;
  
  
  A sealed quarter needs a coherent silhouette
&lt;/h2&gt;

&lt;p&gt;Our lock treatment sits above the quarter art, traffic, neon and buildings. That placement lets the whole area read as sealed. Bright windows or an expressway shining over the treatment would weaken that signal.&lt;/p&gt;

&lt;p&gt;Quest banners sit above it. They are the route into the task, so the player needs to see where a quest is and inspect its status. Each banner carries its own visual lock state. A sealed region can therefore remain readable without exposing the city underneath as though it were open.&lt;/p&gt;

&lt;p&gt;This also keeps the renderers' responsibilities manageable. The layer order decides which scenery the treatment covers. A road renderer can concentrate on roads, while the quest marker handles the status the player needs to understand. Changing the meaning of the lock is a deliberate change to the composition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Distance changes the atmosphere
&lt;/h2&gt;

&lt;p&gt;There are two kinds of fog in the scene. Local road atmosphere belongs with the road effects. A separate city-wide overlay sits above the world and responds to camera zoom.&lt;/p&gt;

&lt;p&gt;At a wider view, the overlay contributes more haze. As the player moves closer, it becomes transparent and the quarter details come forward. The camera distance therefore changes the amount of atmosphere between the player and the city.&lt;/p&gt;

&lt;p&gt;The update follows the camera scale. When the calculated opacity is unchanged, we leave it alone. That small rule reflects a broader habit in a scene with many moving parts: an effect should update the state it actually needs, at the moment it needs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Traffic has a street system underneath it
&lt;/h2&gt;

&lt;p&gt;The cars follow lanes, approach intersections, wait at lights and turn. Those behaviours give the movement a relationship to the road layout. The traffic system also checks the viewport so vehicles outside the visible area do not all need to be drawn.&lt;/p&gt;

&lt;p&gt;Rendering and movement have separate concerns here. A car can leave the picture while its simulation continues. When the camera reaches that road again, the traffic still belongs to the same street system. Nearby off-screen space gives the renderer room to prepare for what will enter the view.&lt;/p&gt;

&lt;p&gt;Animated systems also have a lifecycle. They register updates with the ticker and remove those updates when the system is destroyed. A map that opens, changes and closes needs that cleanup just as much as it needs the animation itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let the artwork arrive around the camera
&lt;/h2&gt;

&lt;p&gt;Illustrated block art does not all have to arrive before the first useful frame. Our streamer prioritises the visible area, orders pending blocks by distance and limits concurrent loads. The rest of the city fills in behind that first view.&lt;/p&gt;

&lt;p&gt;We covered the loading work and its measurements in &lt;a href="https://blog.denshin.io/pixijs-map-loading-on-slow-phones" rel="noopener noreferrer"&gt;our earlier PixiJS performance article&lt;/a&gt;. Here the visual consequence matters: the player should be able to read the street layout while the richer artwork arrives. Loading order is another form of visual priority.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the city as one composition
&lt;/h2&gt;

&lt;p&gt;When we review the map, we look for relationships: labels under cars, markers above sealed areas, atmosphere that follows distance, and lights that sit inside the scene. A screenshot can reveal a misplaced layer. Dragging and zooming reveal whether the same arrangement holds together in motion.&lt;/p&gt;

&lt;p&gt;The image above is a fresh capture from the running city, with the interface outside the frame. Open &lt;a href="https://app.denshin.io" rel="noopener noreferrer"&gt;the live map&lt;/a&gt; and move between the streets and the quarter view. The artwork gives each block its character; the layers, movement and camera make those blocks work as a place to explore.&lt;/p&gt;

</description>
      <category>pixijs</category>
      <category>gamedev</category>
      <category>webgl</category>
      <category>javascript</category>
    </item>
    <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>
