<?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: Rowan Vale</title>
    <description>The latest articles on DEV Community by Rowan Vale (@questdataforge).</description>
    <link>https://dev.to/questdataforge</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%2F4052998%2F10e295b6-e2ea-4131-81db-5dfdf704eb4f.png</url>
      <title>DEV Community: Rowan Vale</title>
      <link>https://dev.to/questdataforge</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/questdataforge"/>
    <language>en</language>
    <item>
      <title>Indexing 6,000+ Map Points for an Extraction Game: Spatial Queries That Don't Stutter</title>
      <dc:creator>Rowan Vale</dc:creator>
      <pubDate>Mon, 03 Aug 2026 10:20:11 +0000</pubDate>
      <link>https://dev.to/questdataforge/indexing-6000-map-points-for-an-extraction-game-spatial-queries-that-dont-stutter-eo2</link>
      <guid>https://dev.to/questdataforge/indexing-6000-map-points-for-an-extraction-game-spatial-queries-that-dont-stutter-eo2</guid>
      <description>&lt;p&gt;Extraction shooters and PvPvE ARPGs put a strange burden on their community tooling. A normal wiki page answers "what is this item?" An interactive map has to answer a much harder question, dozens of times per second, while the user pans around: &lt;strong&gt;which of the thousands of points of interest are inside this rectangle, at this zoom level, with these filters enabled?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I started sketching a companion map for &lt;em&gt;Mistfall Hunter&lt;/em&gt; — Bellring Games' third-person PvPvE extraction ARPG that launched July 29 on Xbox, Steam and Game Pass — the point count looked manageable until it wasn't: spawns, loot containers, objectives, locked doors and extraction zones add up to roughly &lt;strong&gt;6,300 verified coordinates&lt;/strong&gt; across the game's maps. A naive &lt;code&gt;points.filter(p =&amp;gt; inViewport(p))&lt;/code&gt; works in a demo and dies on a mid-range phone. This post walks through the data model and the indexing structure that made the map feel instant, and what I'd do differently next time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model: one table, typed points, no cleverness
&lt;/h2&gt;

&lt;p&gt;The first temptation is to model each point kind as its own entity — a &lt;code&gt;Spawn&lt;/code&gt; table, a &lt;code&gt;LootContainer&lt;/code&gt; table, an &lt;code&gt;ExtractionZone&lt;/code&gt; table. Resist it. On a map, everything is a point with a type and a payload:&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;type&lt;/span&gt; &lt;span class="nx"&gt;PointType&lt;/span&gt; &lt;span class="o"&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;spawn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;objective&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;extraction&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;door&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;MapPoint&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;map&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// map id, if the game has several&lt;/span&gt;
  &lt;span class="nl"&gt;x&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="c1"&gt;// world coordinates, not screen pixels&lt;/span&gt;
  &lt;span class="nl"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PointType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// loot rarity tier, objective difficulty, ...&lt;/span&gt;
  &lt;span class="nl"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// free-form: loot table ref, rotation info, ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single flat collection matters for two reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Filtering is a bitmask, not a join.&lt;/strong&gt; The UI has a row of toggle chips ("show loot", "show extractions"). If types live in separate tables, every pan/zoom/filter change becomes N queries and a merge. With one table, a filter is &lt;code&gt;(point.type &amp;amp; activeMask) !== 0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The index can be built once.&lt;/strong&gt; Whatever spatial structure you choose, it wants a homogeneous array of &lt;code&gt;{x, y, id}&lt;/code&gt; records.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Store world coordinates, never image pixels. The map art will get re-exported at higher resolution the week after you ship, and pixel coordinates silently rot. Keep a single &lt;code&gt;worldToImage&lt;/code&gt; transform per map instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query pattern you're actually optimizing for
&lt;/h2&gt;

&lt;p&gt;Profile before you pick a structure. The real workload of an interactive game map is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dominant query:&lt;/strong&gt; axis-aligned bounding-box (AABB) read — "everything inside the current viewport rect."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frequency:&lt;/strong&gt; every &lt;code&gt;pointermove&lt;/code&gt; / &lt;code&gt;wheel&lt;/code&gt; event, so 30–120 Hz, but cheap to debounce to animation frames.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writes:&lt;/strong&gt; essentially zero at runtime; the dataset ships as a static JSON and changes on redeploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size:&lt;/strong&gt; thousands, not millions. ~6,300 points is 300–600 KB as compact JSON, less after gzip.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That profile — read-heavy, static, medium-N — points directly at a &lt;strong&gt;static structure built at load time&lt;/strong&gt;, not a dynamic quadtree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a quadtree
&lt;/h2&gt;

&lt;p&gt;Quadtrees are the textbook answer and the wrong one here. They shine when points move (players, mobs) or when N is huge. For a static 6k-point dataset:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rebalancing logic is dead code — nothing ever mutates.&lt;/li&gt;
&lt;li&gt;Pointer-chasing through a tree in JS means cache-hostile object graphs.&lt;/li&gt;
&lt;li&gt;AABB queries still visit many internal nodes near the rectangle edge.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What worked: a flat grid hash + typed buckets
&lt;/h2&gt;

&lt;p&gt;The structure I settled on is embarrassingly simple: a &lt;strong&gt;uniform grid over the map's bounding box&lt;/strong&gt;, with each cell holding indexes into the flat point array.&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;class&lt;/span&gt; &lt;span class="nc"&gt;GridIndex&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&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;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;cellSize&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="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MapPoint&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;minX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;minY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;maxX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;maxY&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="nx"&gt;targetCellsPerSide&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&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;maxX&lt;/span&gt; &lt;span class="o"&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;minX&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;h&lt;/span&gt; &lt;span class="o"&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;maxY&lt;/span&gt; &lt;span class="o"&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;minY&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;cellSize&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;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;targetCellsPerSide&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;points&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;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&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;key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cell&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;cells&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="nx"&gt;key&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;cell&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;cells&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cell&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]));&lt;/span&gt;
      &lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&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="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;key&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="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&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="kr"&gt;number&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;cx&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;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;this&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;minX&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="nx"&gt;cellSize&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;cy&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;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;this&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;minY&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="nx"&gt;cellSize&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;cy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// cheap integer hash; 4096 &amp;gt; cells per side&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;minX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;minY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;maxX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;maxY&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="nx"&gt;typeMask&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="nx"&gt;MapPoint&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MapPoint&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x0&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;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minX&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;bounds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minX&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="nx"&gt;cellSize&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;x1&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;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxX&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;bounds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minX&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="nx"&gt;cellSize&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;y0&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;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minY&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;bounds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minY&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="nx"&gt;cellSize&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;y1&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;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxY&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;bounds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minY&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="nx"&gt;cellSize&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;y0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;cy&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;x0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;cx&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cell&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;cells&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="nx"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;cx&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;cell&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;for &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;i&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&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;points&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;typeMask&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;typeIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;))))&lt;/span&gt; &lt;span class="k"&gt;continue&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;p&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;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minX&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;p&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;lt;=&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxX&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
              &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minY&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&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;Two details do the heavy lifting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cell size chosen from N, not vibes.&lt;/strong&gt; With ~6,300 points, 64×64 cells give an average of ~1.5 points per occupied cell. A viewport query touches a few hundred cells, examines a few hundred points, and keeps maybe a few dozen. That's microseconds, not milliseconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The grid returns candidates; the final AABB test returns exact hits.&lt;/strong&gt; Cells overlap rectangle borders, so the inner loop re-checks bounds. Skipping that check is the classic bug that makes markers "bleed" one cell past the viewport edge — harmless until you use the same query for hit-testing clicks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Build cost at load is O(N) and takes ~2 ms for 6k points. The whole index is a &lt;code&gt;Map&amp;lt;number, number[]&amp;gt;&lt;/code&gt; and a typed flat array — trivially serializable, trivially testable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clustering at low zoom is a separate problem
&lt;/h2&gt;

&lt;p&gt;Zoomed all the way out, an AABB query correctly returns 4,000 points — which no renderer and no human wants. Don't solve this by degrading the index; solve it at the rendering layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When &lt;code&gt;query()&lt;/code&gt; returns more than a threshold (I use ~300), switch the renderer to &lt;strong&gt;density rendering&lt;/strong&gt;: accumulate the candidate points into a coarse screen-space grid and draw one cluster badge per occupied cell with a count.&lt;/li&gt;
&lt;li&gt;Keep the &lt;em&gt;same&lt;/em&gt; spatial index for both modes. Clustering libraries that rebuild their own tree per zoom level are wasted work on a static dataset.
&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;typeMask&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;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;renderClusters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;renderMarkers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Click hit-testing: reuse the index in reverse
&lt;/h2&gt;

&lt;p&gt;The other query a game map needs is "which point did the user just click?" — a point query, not a rectangle query. The grid handles it for free: build a tiny AABB around the cursor (say ±8 px converted to world units at current zoom), run the same &lt;code&gt;query()&lt;/code&gt;, then pick the nearest hit by Euclidean distance. No second index, no O(N) scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testable invariants worth writing down
&lt;/h2&gt;

&lt;p&gt;Map data is crowd-verified and drifts. Two cheap property tests caught more bad data than any manual review:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every point lies inside its map's declared bounds.&lt;/strong&gt; Off-by-one-tile coordinates from a data-entry error show up as markers in the void — or worse, silently dropped by the index key function.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Querying the full map bounds returns every point of the requested types.&lt;/strong&gt; This round-trip test catches both index bugs and corrupted payloads in one assertion.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index round-trips all points&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mapBounds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ALL_TYPES_MASK&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&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;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;points&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Seeing it in the wild
&lt;/h2&gt;

&lt;p&gt;If you want to see what this feels like at full scale, the community database &lt;a href="https://mistfallhunter.grandwiki.com/maps" rel="noopener noreferrer"&gt;Mistfall Hunter Wiki &amp;amp; Build Tools&lt;/a&gt; runs exactly this kind of interactive map over its ~6,300 verified spawn/loot/objective/extraction points, layered with its item and recipe data. Toggling the loot/spawn/extraction layers while panning is a live demo of why the filter-as-bitmask + static-grid combination works: the query cost stays flat no matter which layers are on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One flat, typed point collection beats per-type entities for map data.&lt;/li&gt;
&lt;li&gt;Static dataset + AABB-dominant reads = uniform grid hash, not a quadtree.&lt;/li&gt;
&lt;li&gt;Cell size from point density (~1–2 points per cell), exact bounds re-check inside the loop.&lt;/li&gt;
&lt;li&gt;Clustering is a rendering decision, not an indexing decision.&lt;/li&gt;
&lt;li&gt;Round-trip property tests turn map data drift from silent rot into CI failures.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;6,000 points feels like "too many to hand-place, too few to engineer for." It's exactly the range where a hundred lines of deliberate indexing turn a janky demo into something players actually leave open on a second monitor.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>typescript</category>
      <category>algorithms</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Representing Roguelite Build Effects Without Hard-Coding Every Combination</title>
      <dc:creator>Rowan Vale</dc:creator>
      <pubDate>Wed, 29 Jul 2026 10:58:19 +0000</pubDate>
      <link>https://dev.to/questdataforge/representing-roguelite-build-effects-without-hard-coding-every-combination-2e89</link>
      <guid>https://dev.to/questdataforge/representing-roguelite-build-effects-without-hard-coding-every-combination-2e89</guid>
      <description>&lt;p&gt;Build planners for roguelites become fragile when every effect is encoded as a special case. Pass the Fear is a useful example: characters, weapons, relics, tarot cards, battle scars, skill nodes, and weapon parts can all change a run. The combinatorial space grows too quickly for a long chain of &lt;code&gt;if&lt;/code&gt; statements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Describe effects as data
&lt;/h2&gt;

&lt;p&gt;Start with stable IDs and a small vocabulary of operations. A relic effect could be represented as:&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;"sourceId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"relic-example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"trigger"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"on_hit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"operation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"multiply"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"damage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"conditions"&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;"target_burning"&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;The public &lt;a href="https://passthefear.wiki/relics" rel="noopener noreferrer"&gt;relic directory&lt;/a&gt; remains readable for players, while the structured record can be evaluated by tools. The same approach works for the &lt;a href="https://passthefear.wiki/weapons" rel="noopener noreferrer"&gt;weapon database&lt;/a&gt;, where base stats and upgrade effects should be stored separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define an evaluation order
&lt;/h2&gt;

&lt;p&gt;The hard part is not storing a 15% bonus. It is deciding when that bonus applies. A deterministic pipeline might be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;load character base stats;&lt;/li&gt;
&lt;li&gt;apply permanent skill-tree modifiers;&lt;/li&gt;
&lt;li&gt;apply weapon and part modifiers;&lt;/li&gt;
&lt;li&gt;apply relic and tarot modifiers;&lt;/li&gt;
&lt;li&gt;evaluate battle-scar tradeoffs;&lt;/li&gt;
&lt;li&gt;calculate conditional effects for the selected scenario.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Writing the order down prevents two UI screens from producing different totals. It also makes balance patches easier to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep provenance with every fact
&lt;/h2&gt;

&lt;p&gt;Every value should include a source version or verification note. When a patch changes a weapon, the system can flag builds that still depend on an older value instead of silently returning a plausible but incorrect result.&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://passthefear.wiki/characters" rel="noopener noreferrer"&gt;character directory&lt;/a&gt; can expose role and starting-kit data, while the &lt;a href="https://passthefear.wiki/skill-tree" rel="noopener noreferrer"&gt;skill tree&lt;/a&gt; supplies node-level modifiers. The records stay independent but join through stable IDs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the planner explain itself
&lt;/h2&gt;

&lt;p&gt;A useful &lt;a href="https://passthefear.wiki/build-planner" rel="noopener noreferrer"&gt;build planner&lt;/a&gt; should show more than a final score. It should list which sources changed damage, cooldown, survivability, or status effects and in what order. Explainable calculations help players notice mutually exclusive choices and assumptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design for discovery
&lt;/h2&gt;

&lt;p&gt;The main &lt;a href="https://passthefear.wiki/" rel="noopener noreferrer"&gt;Pass the Fear Wiki&lt;/a&gt; ties the systems together, but each tool and directory should link back to the underlying entities. That creates a navigable knowledge graph for players and keeps the site maintainable.&lt;/p&gt;

&lt;p&gt;The general lesson is to treat game effects as composable data, define evaluation order explicitly, and preserve provenance. Then adding a new relic or weapon becomes a content update instead of another hard-coded branch.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>architecture</category>
      <category>webdev</category>
      <category>database</category>
    </item>
    <item>
      <title>Modeling Crafting Dependencies for a Scrap Mechanic Planner</title>
      <dc:creator>Rowan Vale</dc:creator>
      <pubDate>Wed, 29 Jul 2026 10:55:31 +0000</pubDate>
      <link>https://dev.to/questdataforge/modeling-crafting-dependencies-for-a-scrap-mechanic-planner-178i</link>
      <guid>https://dev.to/questdataforge/modeling-crafting-dependencies-for-a-scrap-mechanic-planner-178i</guid>
      <description>&lt;p&gt;Crafting-heavy games look simple until a player asks a practical question: “What do I need to build this machine, including every intermediate part?” At that point a recipe list becomes a dependency-graph problem. Scrap Mechanic is a useful case study because its parts, Craftbot recipes, upgrade chains, crops, and survival raids all affect planning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model recipes as directed edges
&lt;/h2&gt;

&lt;p&gt;Each craftable output should point to its ingredient requirements, while every ingredient should have a stable item ID. A compact representation might look like this:&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;"outputId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"controller-level-3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"quantity"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"station"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"craftbot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ingredients"&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="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"itemId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"component-kit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&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="nl"&gt;"itemId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"circuit-board"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&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="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;A public &lt;a href="https://scrapmech.wiki/database" rel="noopener noreferrer"&gt;parts database&lt;/a&gt; can render the readable item pages, but the same records should power a &lt;a href="https://scrapmech.wiki/crafting" rel="noopener noreferrer"&gt;crafting reference&lt;/a&gt; and any planning tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expand dependencies recursively
&lt;/h2&gt;

&lt;p&gt;A &lt;a href="https://scrapmech.wiki/crafting-planner" rel="noopener noreferrer"&gt;crafting planner&lt;/a&gt; can walk the graph until it reaches raw resources. The important details are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;multiply ingredient quantities by the requested output count;&lt;/li&gt;
&lt;li&gt;merge repeated materials after expansion;&lt;/li&gt;
&lt;li&gt;detect cycles or invalid recipes;&lt;/li&gt;
&lt;li&gt;preserve station requirements;&lt;/li&gt;
&lt;li&gt;let players mark inventory they already own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Memoizing each expanded recipe keeps large plans responsive. It also makes the calculation deterministic enough to test with fixtures whenever a game update changes recipe data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep blueprint analysis separate
&lt;/h2&gt;

&lt;p&gt;A &lt;a href="https://scrapmech.wiki/blueprint-analyzer" rel="noopener noreferrer"&gt;blueprint analyzer&lt;/a&gt; has a different input grain. It starts with a serialized creation, counts parts by UUID, and joins those IDs back to the item database. Keeping this parser separate from the recipe graph makes both systems easier to validate.&lt;/p&gt;

&lt;p&gt;The useful output is not only total block count. Mass, component categories, controller usage, bearings, pistons, and missing or modded UUIDs help a builder understand why a creation behaves the way it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat raids as another calculator
&lt;/h2&gt;

&lt;p&gt;Survival planning also depends on farm value and timing. A &lt;a href="https://scrapmech.wiki/raid-calculator" rel="noopener noreferrer"&gt;raid calculator&lt;/a&gt; can translate planted crops into an expected raid level, then explain the thresholds instead of presenting a mystery number.&lt;/p&gt;

&lt;h2&gt;
  
  
  One source, several interfaces
&lt;/h2&gt;

&lt;p&gt;The main &lt;a href="https://scrapmech.wiki/" rel="noopener noreferrer"&gt;Scrap Mechanic Wiki&lt;/a&gt; connects these views, but the architecture works because the tools do not maintain competing copies of the same facts. Normalize item IDs, version the source data, validate every relationship, and generate player-facing tools from that shared model.&lt;/p&gt;

&lt;p&gt;That pattern applies well beyond one game: dependency graphs are the right abstraction whenever crafting, upgrades, and inventories overlap.&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>webdev</category>
      <category>database</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>Designing a Data-First Companion Wiki for DragonSword: Awakening</title>
      <dc:creator>Rowan Vale</dc:creator>
      <pubDate>Wed, 29 Jul 2026 10:46:36 +0000</pubDate>
      <link>https://dev.to/questdataforge/designing-a-data-first-companion-wiki-for-dragonsword-awakening-5540</link>
      <guid>https://dev.to/questdataforge/designing-a-data-first-companion-wiki-for-dragonsword-awakening-5540</guid>
      <description>&lt;p&gt;A game wiki becomes much more useful when it is treated as a small data product instead of a stack of prose pages. DragonSword: Awakening is a good example because its 19 playable heroes, Tag-team interactions, upgrade materials, and exploration data all overlap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a normalized content model
&lt;/h2&gt;

&lt;p&gt;I would keep heroes, roles, elements, skills, materials, recipes, and map locations as separate entities, then connect them through stable IDs. That avoids copying the same material or skill data into multiple pages and makes later balance updates less error-prone.&lt;/p&gt;

&lt;p&gt;A hero record can stay small:&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;"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;"hero-slug"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"breaker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"element"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fire"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tagTriggers"&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;"launch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stun"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"materialIds"&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;"mat-a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mat-b"&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;The public &lt;a href="https://dsawakening.wiki/heroes" rel="noopener noreferrer"&gt;hero directory&lt;/a&gt; is the human-readable view, while the same relationships can drive filters and comparison tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build tools from the same source
&lt;/h2&gt;

&lt;p&gt;Once the content is normalized, a &lt;a href="https://dsawakening.wiki/team-build" rel="noopener noreferrer"&gt;team builder&lt;/a&gt; should not need a second hand-maintained dataset. It can derive role coverage, Tag-chain compatibility, and missing support functions from the hero records.&lt;/p&gt;

&lt;p&gt;The same principle applies to a &lt;a href="https://dsawakening.wiki/material-planner" rel="noopener noreferrer"&gt;material planner&lt;/a&gt;: let users select upgrade goals, aggregate required items by ID, and show which requirements are shared across heroes. This is more useful than forcing people to maintain a spreadsheet beside the wiki.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat maps as structured data
&lt;/h2&gt;

&lt;p&gt;An &lt;a href="https://dsawakening.wiki/map" rel="noopener noreferrer"&gt;interactive map&lt;/a&gt; works best when each marker stores a type, region, coordinates, prerequisites, and source notes. That makes filtering and later validation much easier than embedding coordinates directly in presentation code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design for discovery
&lt;/h2&gt;

&lt;p&gt;The main &lt;a href="https://dsawakening.wiki/" rel="noopener noreferrer"&gt;DragonSword: Awakening Wiki&lt;/a&gt; links the datasets and tools together, but every entity page should also provide contextual links back to related heroes, items, bosses, or locations. This helps both players and crawlers discover the graph naturally.&lt;/p&gt;

&lt;p&gt;The broader lesson is simple: store facts once, derive tools from the same model, and make every useful record reachable from navigation and search. A companion site built this way can survive patches without turning each update into a manual rewrite.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>database</category>
      <category>gamedev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
