<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Small Water Blog</title>
    <link>https://smallwater.eu/en/blog/</link>
    <description>Apps, web tools and games from a small software company in Slovakia.</description>
    <language>en</language>
    <atom:link href="https://smallwater.eu/en/blog/feed.xml" rel="self" type="application/rss+xml" />
    <lastBuildDate>Mon, 09 Nov 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>How to build LOD for thousands of animated Unity characters</title>
      <link>https://smallwater.eu/en/blog/unity-lod-vertex-animation-crowd/</link>
      <guid isPermaLink="true">https://smallwater.eu/en/blog/unity-lod-vertex-animation-crowd/</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <category>Tower Defense</category>
      <description>A guide to crowd LOD in Unity with vertex animation textures: why vertices matter more than triangles, LOD as a new mesh, and per-entity selection.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/cover.webp" /><img src="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/cover.jpg" alt="Three versions of the same skeleton character with decreasing triangle counts from left to right" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Contents"><p class="post-toc-title">Contents</p><ol><li><a href="#why-generated-lod-does-not-help-a-crowd" data-ga="blog_toc_clicked" data-ga-heading-id="why-generated-lod-does-not-help-a-crowd">Why generated LOD does not help a crowd</a></li><li><a href="#count-vertices-not-triangles" data-ga="blog_toc_clicked" data-ga-heading-id="count-vertices-not-triangles">Count vertices, not triangles</a></li><li><a href="#step-1-reduce-vertices-in-lod-0" data-ga="blog_toc_clicked" data-ga-heading-id="step-1-reduce-vertices-in-lod-0">Step 1: Reduce vertices in LOD 0</a></li><li><a href="#step-2-lod-1-from-simplification-with-a-shared-texture" data-ga="blog_toc_clicked" data-ga-heading-id="step-2-lod-1-from-simplification-with-a-shared-texture">Step 2: LOD 1 from simplification with a shared texture</a></li><li><a href="#step-3-lod-2-as-a-new-mesh-attached-to-the-original" data-ga="blog_toc_clicked" data-ga-heading-id="step-3-lod-2-as-a-new-mesh-attached-to-the-original">Step 3: LOD 2 as a new mesh attached to the original</a></li><li><a href="#step-4-check-for-holes-automatically" data-ga="blog_toc_clicked" data-ga-heading-id="step-4-check-for-holes-automatically">Step 4: Check for holes automatically</a></li><li><a href="#step-5-pick-the-level-per-character" data-ga="blog_toc_clicked" data-ga-heading-id="step-5-pick-the-level-per-character">Step 5: Pick the level per character</a></li><li><a href="#results-and-limits" data-ga="blog_toc_clicked" data-ga-heading-id="results-and-limits">Results and limits</a></li></ol></nav><p>Build LOD for thousands of animated characters in Unity around vertex count, not triangle count: with vertex animation textures, animation is computed per vertex. Prepare the lower levels by hand, attach the vertices of the lowest level to the surface of the original, and pick the level for each character separately. In our PC tower defense game with 3,000 enemies, this raised FPS when zoomed into the crowd from 62 to 120.</p>
<p>This applies to games where thousands of characters move on screen at once: tower defense, strategy and survival games. Ours is built in Unity with ECS and listed in our <a href="https://smallwater.eu/en/#referencie">portfolio</a>. LOD stands for <a href="https://en.wikipedia.org/wiki/Level_of_detail_(computer_graphics)" target="_blank" rel="noopener">level of detail</a>: a distant character is drawn from a simpler mesh. The images show the actual meshes of our skeleton enemy, not in-game shots.</p>
<h2 id="why-generated-lod-does-not-help-a-crowd"><a class="heading-anchor" href="#why-generated-lod-does-not-help-a-crowd" aria-hidden="true" tabindex="-1">#</a>Why generated LOD does not help a crowd</h2>
<p>Unity can generate LOD levels when it imports a model (the <a href="https://docs.unity3d.com/6000.2/Documentation/Manual/lod/mesh-lod-introduction.html" target="_blank" rel="noopener">Mesh LOD</a> feature). For thousands of characters that has two problems.</p>
<p>The first is appearance. Automatic simplification tore thin limbs and broke the UVs, the mapping of the texture onto the mesh.</p>
<p>The second is the cost of animation. Standard skeletal animation is too expensive for thousands of entities, so vertex animation textures (VAT) are used instead. A VAT is a texture where each column belongs to one vertex and each row to one animation frame. The shader reads the vertex position from the texture, so the cost of animation depends on how many vertices are read, including when shadows are drawn.</p>
<p>A test with one forced generated level on every character shows the ceiling (Unity also generates levels 3 and 4; build, 1080p, 3,000 enemies, shadows on):</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Forced level</th>
<th>Triangles</th>
<th>FPS</th>
</tr>
</thead>
<tbody><tr>
<td>LOD 0</td>
<td>11 million</td>
<td>72</td>
</tr>
<tr>
<td>LOD 1</td>
<td>6 million</td>
<td>96</td>
</tr>
<tr>
<td>LOD 2</td>
<td>3.68 million</td>
<td>120</td>
</tr>
<tr>
<td>LOD 4</td>
<td>1.43 million</td>
<td>201</td>
</tr>
</tbody></table>
</div>
<p>There is plenty of room for speedup. The goal is to get it without visible holes or jumps between levels.</p>
<h2 id="count-vertices-not-triangles"><a class="heading-anchor" href="#count-vertices-not-triangles" aria-hidden="true" tabindex="-1">#</a>Count vertices, not triangles</h2>
<p>Halving the triangles does not help on its own. A LOD with half the triangles and 996 of the original 1,521 vertices saved 1.9 ms per frame, a LOD with 217 vertices saved 6.4 ms. Track both counts for every level.</p>
<p>Meshes often carry far too many vertices. A vertex is duplicated at every UV seam, because one position then needs two texture coordinates. Our models from an AI generator had UVs chopped into hundreds of islands: the skeleton had 1,521 vertices but only 608 distinct combinations of position and normal.</p>
<h2 id="step-1-reduce-vertices-in-lod-0"><a class="heading-anchor" href="#step-1-reduce-vertices-in-lod-0" aria-hidden="true" tabindex="-1">#</a>Step 1: Reduce vertices in LOD 0</h2>
<p>The first saving costs no detail. Merge vertices with the same position and a similar normal (within 25°), unwrap the UVs again with seams only on sharp edges (over 70°), and rebake the texture from the old one triangle by triangle. The skeleton dropped from 1,521 to 1,090 vertices; other enemy types lost 19 to 29% with the same shape.</p>
<p>Then write only the columns of vertices that some level actually uses into the VAT. That saved 40 to 60% of animation memory.</p>
<h2 id="step-2-lod-1-from-simplification-with-a-shared-texture"><a class="heading-anchor" href="#step-2-lod-1-from-simplification-with-a-shared-texture" aria-hidden="true" tabindex="-1">#</a>Step 2: LOD 1 from simplification with a shared texture</h2>
<p>LOD 1 is LOD 0 simplified to roughly 52% of the triangles. Simplify piece by piece (each combination of bone and connected part of the mesh) and lock the vertices on the piece boundaries. Give the head a larger budget, since its silhouette is what the eye picks out in a crowd.</p>
<p>Keep the LOD 0 texture. A level with its own texture or a different shape is visible at the transition even with a similar silhouette.</p>
<p>Below about 50% of the triangles, simplifying from existing vertices tears thin parts and separate pieces. Going lower needs a different approach.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/levels.webp" /><img src="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/levels.png" alt="Three versions of the skeleton character side by side with decreasing triangle counts" width="2400" height="1240" loading="lazy" decoding="async" /></picture><figcaption>The same skeleton at three levels: 873, 448 and 330 triangles, 1,090, 705 and 520 vertices.</figcaption></figure>
<h2 id="step-3-lod-2-as-a-new-mesh-attached-to-the-original"><a class="heading-anchor" href="#step-3-lod-2-as-a-new-mesh-attached-to-the-original" aria-hidden="true" tabindex="-1">#</a>Step 3: LOD 2 as a new mesh attached to the original</h2>
<p>The lowest level is a new closed mesh. Thicken the original with a shell layer, run a voxel remesh and simplify to a target count (for us 200 to 460 triangles depending on the enemy type). Shrink-wrap the result onto the surface of the original and generate UVs automatically. Bake the texture from the original at the same density as LOD 0 so the mipmaps match too.</p>
<p>The new mesh has no animation weights. Attach each of its vertices to the nearest triangle of the original and store the three columns of that triangle and the barycentric weights (the ratios that define a point&#39;s position inside a triangle).</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/attach.webp" /><img src="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/attach.png" alt="Diagram: a red vertex of the simplified mesh is linked by dashed lines to the three vertices of an original triangle" width="1800" height="840" loading="lazy" decoding="async" /></picture><figcaption>A vertex of the lower level (red) moves with the three vertices of the nearest original triangle.</figcaption></figure>
<p>The VAT column of an attached vertex is the weighted sum of the positions and normals of those three vertices in every frame. The shader does nothing special, an attached vertex is just another column.</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">for every frame f:
    VAT[attached, f] = w0 * VAT[a, f] + w1 * VAT[b, f] + w2 * VAT[c, f]</code></pre>
<h2 id="step-4-check-for-holes-automatically"><a class="heading-anchor" href="#step-4-check-for-holes-automatically" aria-hidden="true" tabindex="-1">#</a>Step 4: Check for holes automatically</h2>
<p>Images from a modeling tool do not reliably reveal holes. A simple check is enough: render the original and the candidate from 11 directions at 120 px and compare the silhouettes.</p>
<p>Count as a hole the pixels of the original inside the candidate&#39;s outline that the candidate does not cover, plus candidate pixels more than 2 px outside the original&#39;s outline. Our limits are 1% holes and 4 to 6% loss. The check must respect back-face culling per material. We set the limits based on where the transitions were visible in the game.</p>
<h2 id="step-5-pick-the-level-per-character"><a class="heading-anchor" href="#step-5-pick-the-level-per-character" aria-hidden="true" tabindex="-1">#</a>Step 5: Pick the level per character</h2>
<p>Switching the whole map to a level at once is visible. Picking per entity spreads the transitions out and they disappear in the crowd.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Whole map at once</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/select-global.webp" /><img src="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/select-global.png" alt="A crowd of dots in one color, all characters at the same level" width="1120" height="840" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Per entity</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/select-entity.webp" /><img src="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/select-entity.png" alt="A crowd of dots in bands by distance from the camera, near ones red, far ones gray" width="1120" height="840" loading="lazy" decoding="async" /></picture></div></div><figcaption>Top view, camera at the bottom. Color is the LOD level: red 0, orange 1, gray 2.</figcaption></figure>
<p>A version warning: the Unity 6.2 documentation states that Entities Graphics always uses LOD 0. We use per-entity selection by the formula below in Unity 6.6, so verify the behavior of your version first:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">LOD = log2(d * C / D) * slope + offset
C = threshold * 2 * tan(fov / 2) / screen_height</code></pre>
<p>Here <code>d</code> is the distance from the camera and <code>D</code> is the diameter of the entity&#39;s world bounds. Choose slope and offset so that level 1 starts at 93.3 and level 2 at 167.9 meters (camera with 30° fov and 1,440 px). Set the global LOD threshold to screen height / 1440 so the distances do not depend on the resolution.</p>
<p>Two mistakes to avoid:</p>
<ul>
<li><strong>Scale.</strong> Compute the diameter <code>D</code> from world bounds, including the prefab scale. Meshes for VAT tend to be in small units and the prefab enlarges them 2 to 4 times. Without this, the game draws LOD 0 all the time and reports nothing. Verify it by comparing triangle counts in normal mode against a forced LOD. Rotating an entity enlarges its axis-aligned bounds, so we multiply the diameter by a factor of 1.22.</li>
<li><strong>Baking.</strong> The selection curve is baked with the SubScene and baking does not pick up a change in the mesh. Force a SubScene rebake after editing it.</li>
</ul>
<h2 id="results-and-limits"><a class="heading-anchor" href="#results-and-limits" aria-hidden="true" tabindex="-1">#</a>Results and limits</h2>
<p>macOS build, 2560 × 1440, 3,000 enemies, shadows on, game stopped after game over (VAT animations keep running, so the measurement holds for graphics):</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Scene</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>Whole map</td>
<td>90 FPS</td>
<td>130 FPS</td>
</tr>
<tr>
<td>Zoomed into the crowd</td>
<td>62 FPS</td>
<td>120 FPS</td>
</tr>
</tbody></table>
</div>
<p>On the <a href="https://www.steamdeck.com/en/tech" target="_blank" rel="noopener">Steam Deck</a> (1280 × 800, 8 RDNA 2 compute units) it is 75 to 80 FPS on the whole map. The closest view into the crowd runs at 33 FPS with shadows on high and 64 with shadows off. Shadows are the largest remaining cost: LOD lowers them too, but turning them off still gives a large FPS gain.</p>
<p>What LOD does not solve: the hole check runs in T-pose, so it does not catch stretching in extreme poses. One enemy type still exceeds the hole limit in LOD 2. LOD does not change the cost of moving the horde either; that is covered by the article on <a href="https://smallwater.eu/en/blog/unity-dots-horde-navigation/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/unity-lod-vertex-animation-crowd/" data-ga-destination="/en/blog/unity-dots-horde-navigation/" data-ga-placement="inline">horde navigation in Unity DOTS</a>, and reactions to explosions by the article on <a href="https://smallwater.eu/en/blog/fake-ragdoll-unity-dots/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/unity-lod-vertex-animation-crowd/" data-ga-destination="/en/blog/fake-ragdoll-unity-dots/" data-ga-placement="inline">fake ragdoll</a>.</p>
<p>Need help with game performance in Unity or crowd simulation? <a href="https://smallwater.eu/en/#kontakt">Get in touch</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to add glow and 2D lights to an old 2D Unity URP game</title>
      <link>https://smallwater.eu/en/blog/dreamica-remaster-unity-urp-glow/</link>
      <guid isPermaLink="true">https://smallwater.eu/en/blog/dreamica-remaster-unity-urp-glow/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Dreamica</category>
      <description>How to give an old 2D Unity game selective glow with URP Bloom and HDR, dynamic 2D lights and a sharp UI for current screens, using the game Dreamica.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/cover.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/cover.jpg" alt="Three shots from Dreamica: a ghost chasing a bubble, a squirrel with a cannon and glowing lasers in a forest" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Contents"><p class="post-toc-title">Contents</p><ol><li><a href="#why-the-old-full-screen-glow-is-not-enough" data-ga="blog_toc_clicked" data-ga-heading-id="why-the-old-full-screen-glow-is-not-enough">Why the old full-screen glow is not enough</a></li><li><a href="#step-1-switch-the-project-to-urp" data-ga="blog_toc_clicked" data-ga-heading-id="step-1-switch-the-project-to-urp">Step 1: Switch the project to URP</a><ol><li><a href="#a-grey-screen-set-up-a-camera-stack" data-ga="blog_toc_clicked" data-ga-heading-id="a-grey-screen-set-up-a-camera-stack">A grey screen: set up a camera stack</a></li><li><a href="#sprites-ignore-lights-switch-to-the-2d-renderer" data-ga="blog_toc_clicked" data-ga-heading-id="sprites-ignore-lights-switch-to-the-2d-renderer">Sprites ignore lights: switch to the 2D renderer</a></li><li><a href="#particles-and-old-plugins" data-ga="blog_toc_clicked" data-ga-heading-id="particles-and-old-plugins">Particles and old plugins</a></li></ol></li><li><a href="#step-2-selective-glow-with-hdr-colour-and-bloom-at-threshold-1" data-ga="blog_toc_clicked" data-ga-heading-id="step-2-selective-glow-with-hdr-colour-and-bloom-at-threshold-1">Step 2: Selective glow with HDR colour and Bloom at threshold 1</a><ol><li><a href="#emission-map-only-part-of-the-sprite-glows" data-ga="blog_toc_clicked" data-ga-heading-id="emission-map-only-part-of-the-sprite-glows">Emission map: only part of the sprite glows</a></li><li><a href="#pulsing-in-exposure-stops" data-ga="blog_toc_clicked" data-ga-heading-id="pulsing-in-exposure-stops">Pulsing in exposure stops</a></li></ol></li><li><a href="#step-3-a-dynamic-2d-light-around-the-player" data-ga="blog_toc_clicked" data-ga-heading-id="step-3-a-dynamic-2d-light-around-the-player">Step 3: A dynamic 2D light around the player</a><ol><li><a href="#the-same-shader-on-adjoining-surfaces" data-ga="blog_toc_clicked" data-ga-heading-id="the-same-shader-on-adjoining-surfaces">The same shader on adjoining surfaces</a></li><li><a href="#surge-blend-to-a-peak-instead-of-adding" data-ga="blog_toc_clicked" data-ga-heading-id="surge-blend-to-a-peak-instead-of-adding">Surge: blend to a peak instead of adding</a></li></ol></li><li><a href="#step-4-a-sharp-ui-for-current-screens" data-ga="blog_toc_clicked" data-ga-heading-id="step-4-a-sharp-ui-for-current-screens">Step 4: A sharp UI for current screens</a></li><li><a href="#limits-uncompressed-textures" data-ga="blog_toc_clicked" data-ga-heading-id="limits-uncompressed-textures">Limits: uncompressed textures</a></li></ol></nav><p>An old 2D Unity game on the built-in render pipeline looks flat on a current phone: there is no lighting, glow is either missing or lights up the whole frame including the UI, and the interface sprites are blurry. In Unity URP you fix this with three things: Bloom with a threshold of 1 plus HDR colour only on objects that should glow, 2D lights from the 2D renderer, and a UI with larger sprites that respects the screen&#39;s safe area.</p>
<p>We show the process on our game Dreamica (<a href="https://smallwater.eu/dreamica-ios">App Store</a>, <a href="https://smallwater.eu/dreamica-android">Google Play</a>). It is a falling adventure where you draw lines with your finger to guide a bubble with a sleeping child through four worlds. It was originally built in Unity 5.3, with the UI in the NGUI plugin, glow from the MK Glow plugin and art made for the iPhone 7 and its 1334 × 750 display. The gameplay stayed the same; only the graphics changed.</p>
<p>You often reopen an old game because of the store, not the graphics. Since August 2019 Google Play <a href="https://android-developers.googleblog.com/2019/01/get-your-apps-ready-for-64-bit.html" target="_blank" rel="noopener">requires 64-bit versions</a>, and a game with only a 32-bit build drops off it. That is exactly what happened to Dreamica. Once you are moving the project to Unity 6 anyway, the graphics are a relatively cheap bonus.</p>
<h2 id="why-the-old-full-screen-glow-is-not-enough"><a class="heading-anchor" href="#why-the-old-full-screen-glow-is-not-enough" aria-hidden="true" tabindex="-1">#</a>Why the old full-screen glow is not enough</h2>
<p>Older glow plugins work as a full-screen effect. They light up everything bright, including the icon buttons in the menu. In Dreamica, MK Glow in Unity 6 made the icons nearly vanish. Unity&#39;s official Post Processing Stack v2 only replaces it for the built-in pipeline and no longer applies in URP. If you want glow you control per material, plus dynamic 2D lights, move to URP and use the Bloom built into it.</p>
<h2 id="step-1-switch-the-project-to-urp"><a class="heading-anchor" href="#step-1-switch-the-project-to-urp" aria-hidden="true" tabindex="-1">#</a>Step 1: Switch the project to URP</h2>
<p>After switching to URP, an old 2D game will almost certainly hit two traps. Both show up as a picture that looks wrong, or exactly the same as before, even though the shaders are fine.</p>
<h3 id="a-grey-screen-set-up-a-camera-stack"><a class="heading-anchor" href="#a-grey-screen-set-up-a-camera-stack" aria-hidden="true" tabindex="-1">#</a>A grey screen: set up a camera stack</h3>
<p>If the Game view in the Editor shows nothing but a grey area while the Scene view and a one-off camera render look fine, the problem is not in the shaders but in how the cameras are combined. Old games often have two cameras, one for the world and one for the UI, and the UI camera only clears depth. That is how cameras are layered in the built-in pipeline, and URP does not support it. It needs an explicit camera stack: one camera is the Base, the others are Overlays added to its stack. Set it up in every scene where cameras are layered.</p>
<h3 id="sprites-ignore-lights-switch-to-the-2d-renderer"><a class="heading-anchor" href="#sprites-ignore-lights-switch-to-the-2d-renderer" aria-hidden="true" tabindex="-1">#</a>Sprites ignore lights: switch to the 2D renderer</h3>
<p>Sprites with a lit shader ignore every light if the regular URP renderer is active. It does not compute 2D lights at all. Switch to the 2D renderer. Even that one draws no lighting until the scene has at least one 2D light. When something looks exactly the same after a change, first check which renderer is running.</p>
<h3 id="particles-and-old-plugins"><a class="heading-anchor" href="#particles-and-old-plugins" aria-hidden="true" tabindex="-1">#</a>Particles and old plugins</h3>
<p>Particles on old shaders render black, or not at all, under the 2D renderer. Give them a new, simple shader.</p>
<p>Watch the Asset Store too. During an asset refresh the Editor can apply an &quot;update&quot; to an old plugin on its own, adding metadata and renaming shaders. Materials find their shader by name, so they break silently. After every larger asset refresh in an old project, review all changed files in version control, including the ones you are not working on.</p>
<h2 id="step-2-selective-glow-with-hdr-colour-and-bloom-at-threshold-1"><a class="heading-anchor" href="#step-2-selective-glow-with-hdr-colour-and-bloom-at-threshold-1" aria-hidden="true" tabindex="-1">#</a>Step 2: Selective glow with HDR colour and Bloom at threshold 1</h2>
<p>The goal is for selected objects to glow (in Dreamica the drawn line, lasers, sparks, fireflies and runes) but not the backgrounds or the UI. Bloom in URP blurs and adds everything brighter than its <a href="https://docs.unity3d.com/6/Documentation/Manual/urp/post-processing-bloom.html" target="_blank" rel="noopener">threshold</a>. Set the threshold to 1 and push the colour of the materials that should glow above 1.</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">line shader:
    colour = texture × vertex colour × tint × intensity    (intensity = 6)

Bloom:
    threshold = 1, intensity = 0.3
    only what ends up brighter than 1 glows</code></pre>
<p>Backgrounds and buttons have colours between 0 and 1, so they do not pass the threshold and stay sharp. That also gets rid of the washed-out icons. The camera must have HDR enabled. Without it the colour is clamped to 1 and nothing glows. The 2D renderer does not show the HDR toggle in the camera inspector, so enable it from a script.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Low threshold, whole frame</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/glow-fullscreen.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/glow-fullscreen.jpg" alt="Full-screen glow: the line, the button rim and bright parts of the background all glow" width="1440" height="840" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Threshold 1, HDR material</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/glow-selective.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/glow-selective.jpg" alt="Selective glow: only the drawn line glows, the button and background stay sharp" width="1440" height="840" loading="lazy" decoding="async" /></picture></div></div><figcaption>An illustration of the principle. Left: full-screen glow with a low threshold. Right: only the material with intensity above 1 glows.</figcaption></figure>
<p>Once glow is under control like this, it does not have to be optional. In Dreamica we removed the glow toggle from the settings; glow is part of the look.</p>
<h3 id="emission-map-only-part-of-the-sprite-glows"><a class="heading-anchor" href="#emission-map-only-part-of-the-sprite-glows" aria-hidden="true" tabindex="-1">#</a>Emission map: only part of the sprite glows</h3>
<p>If only part of a sprite should glow, give it an emission map: a second, black and white texture that marks which part of the sprite glows. Only that part gets a colour above 1, so on a rune the core shines, not the whole outline.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/rune-emission.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/rune-emission.png" alt="A rune, its black and white emission map and the result with a glowing core" width="1800" height="600" loading="lazy" decoding="async" /></picture><figcaption>Rune sprite, emission map (white glows) and the result after Bloom.</figcaption></figure>
<h3 id="pulsing-in-exposure-stops"><a class="heading-anchor" href="#pulsing-in-exposure-stops" aria-hidden="true" tabindex="-1">#</a>Pulsing in exposure stops</h3>
<p>If the glow should pulse, do not multiply the colour linearly. An HDR material can have a brightness around 2 to the power of 8, and a linear multiplier from 0 to 10 makes no visible difference on it. Compute the pulse in exposure stops (EV), the same way as the intensity slider in Unity&#39;s HDR colour picker:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">colour = tint × 2 ^ ev,   ev oscillates between a minimum and a maximum</code></pre>
<h2 id="step-3-a-dynamic-2d-light-around-the-player"><a class="heading-anchor" href="#step-3-a-dynamic-2d-light-around-the-player" aria-hidden="true" tabindex="-1">#</a>Step 3: A dynamic 2D light around the player</h2>
<p>Put a 2D point light on the player with a soft flare around it. In Dreamica a single sine wave drives the flare brightness, the light radius and the light intensity together, so they pulse as one. The environment can set the light colour: white in the forest, orange in the swamp, purple in winter, yellow in hell. Switch backgrounds and obstacles to lit shaders, otherwise they ignore the light.</p>
<p>Short lights bring events to life. When the squirrel&#39;s cannon fires, an amber light with intensity 3 flashes and fades out over 0.3 seconds, and the muzzle sparks glow. Not everything has to glow: the dust when the acorn disappears is brown on purpose and does not glow.</p>
<h3 id="the-same-shader-on-adjoining-surfaces"><a class="heading-anchor" href="#the-same-shader-on-adjoining-surfaces" aria-hidden="true" tabindex="-1">#</a>The same shader on adjoining surfaces</h3>
<p>If two backgrounds join up, the source images have the same brightness and there is still a visible seam, check the shaders. In Dreamica the opening background and the endless forest background each used a different shader. One ignored the material tint of 0.733, the other applied it and darkened the forest to 73%. The fix is the same shader on both surfaces.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Two shaders</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/seam-before.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/seam-before.jpg" alt="The border between two forest backgrounds, the lower part is clearly darker" width="1024" height="1120" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">One shader</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/seam-after.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/seam-after.jpg" alt="The same border, both backgrounds have the same brightness" width="1024" height="1120" loading="lazy" decoding="async" /></picture></div></div><figcaption>The border between the opening background and the forest. Left: the lower part darkened by the 0.733 tint that the upper shader ignores.</figcaption></figure>
<h3 id="surge-blend-to-a-peak-instead-of-adding"><a class="heading-anchor" href="#surge-blend-to-a-peak-instead-of-adding" aria-hidden="true" tabindex="-1">#</a>Surge: blend to a peak instead of adding</h3>
<p>On the way into the next world the light and flare surge: 0.6 seconds up, a short hold, 1.4 seconds down, and the flare turns once. If you simply add the surge on top of the pulse, the peak depends on where the sine wave happens to be, and every surge has a different strength. Blend smoothly from the pulse to a fixed peak instead:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">weight = surge curve (0 to 1)
value = pulse + (peak - pulse) × weight</code></pre>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Added on top</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/surge-additive.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/surge-additive.png" alt="Intensity chart: the pulse and two surges with peaks of different height" width="1440" height="720" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Blend to peak</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/surge-lerp.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/surge-lerp.png" alt="Intensity chart: the pulse and two surges reaching the same peak on the dotted line" width="1440" height="720" loading="lazy" decoding="async" /></picture></div></div><figcaption>Two surges at different phases of the pulse (orange and blue). Left: the peaks differ. Right: both reach the same value.</figcaption></figure>
<aside class="app-box app-box-callout"><img class="app-box-img" src="https://smallwater.eu/images/dreamica/1-chase.jpg" alt="" width="700" height="1515" loading="lazy" decoding="async" /><div class="app-box-body"><p class="app-box-eyebrow">Try it</p><h2 class="app-box-title">Dreamica</h2><p>A hand-drawn falling adventure where you draw physical lines to guide a magic bubble. Development notes and updates.</p><div class="app-box-links"><a class="btn btn-primary" href="https://smallwater.eu/dreamica-ios" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="dreamica" data-ga-destination="/dreamica-ios" data-ga-app="dreamica" data-ga-store="app_store">App Store</a><a class="btn btn-ghost" href="https://smallwater.eu/dreamica-android" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="dreamica" data-ga-destination="/dreamica-android" data-ga-app="dreamica" data-ga-store="google_play">Google Play</a></div></div></aside>
<h2 id="step-4-a-sharp-ui-for-current-screens"><a class="heading-anchor" href="#step-4-a-sharp-ui-for-current-screens" aria-hidden="true" tabindex="-1">#</a>Step 4: A sharp UI for current screens</h2>
<p>A UI drawn for the iPhone 7 has small sprites. Dreamica&#39;s menu button sprite was 128 × 64 pixels, and on a phone with three times the pixel density it was stretched several times over. Redraw the sprites at a higher resolution: the new button is 800 × 434, and the progress bars grew from 200 × 26 to 800 × 104.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">128 × 64</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/button-old.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/button-old.jpg" alt="Close-up of a blurry button edge" width="1120" height="608" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">800 × 434</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/button-new.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/button-new.jpg" alt="Close-up of a sharp button edge with visible texture" width="1120" height="608" loading="lazy" decoding="async" /></picture></div></div><figcaption>The same button at the same on-screen size. Left: the original sprite, right: the new one.</figcaption></figure>
<p>If the UI is built on NGUI, consider a rewrite. NGUI anchors resolve piece by piece across several frames, long anchor chains sometimes settle in the wrong place, and the scale differs between the Editor, a phone and an iPad. In Dreamica fixes did not make it reliable, so we rewrote the UI in uGUI and TextMeshPro. Go screen by screen and keep the old version running next to it as a reference. Scale the UI by height against a reference resolution (434 × 941 points in our case) and respect the screen&#39;s safe area, meaning the notch and rounded corners. When the rewrite is done, remove NGUI from the project entirely so you do not end up with two UI systems.</p>
<h2 id="limits-uncompressed-textures"><a class="heading-anchor" href="#limits-uncompressed-textures" aria-hidden="true" tabindex="-1">#</a>Limits: uncompressed textures</h2>
<p>This process only changes the graphics. In Dreamica we did not compress textures, on purpose, because we wanted full sharpness. The price is size: even the original game had 313 MB of textures in memory in the Editor, and it is 299 MB on the App Store. By our estimate compression would shrink the textures to roughly 40 MB. If game size matters to you, that is the first place to save.</p>
<p>For another Unity game from the first line of code to release, see the post on <a href="https://smallwater.eu/en/blog/shmup-apple-watch-devlog/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/dreamica-remaster-unity-urp-glow/" data-ga-destination="/en/blog/shmup-apple-watch-devlog/" data-ga-placement="inline">Space Impact Watch</a>; performance in Unity is covered in the post on <a href="https://smallwater.eu/en/blog/unity-dots-horde-navigation/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/dreamica-remaster-unity-urp-glow/" data-ga-destination="/en/blog/unity-dots-horde-navigation/" data-ga-placement="inline">horde navigation in Unity DOTS</a>. If the game is still on Unity 4, don&#39;t open it directly in Unity 6; the safe route is described in <a href="https://smallwater.eu/en/blog/unity-4-to-unity-6-upgrade/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/dreamica-remaster-unity-urp-glow/" data-ga-destination="/en/blog/unity-4-to-unity-6-upgrade/" data-ga-placement="inline">how to upgrade a Unity 4 project to Unity 6</a>. If you have an old Unity game that dropped off the stores and you want it back, <a href="https://smallwater.eu/en/#kontakt">get in touch</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to fake ragdolls for thousands of enemies in Unity DOTS</title>
      <link>https://smallwater.eu/en/blog/fake-ragdoll-unity-dots/</link>
      <guid isPermaLink="true">https://smallwater.eu/en/blog/fake-ragdoll-unity-dots/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Tower Defense</category>
      <description>How to knock thousands of enemies flying in Unity DOTS without bone physics: the body as one point, deterministic launch, walkable landing and tumbling.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/cover.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/cover.jpg" alt="Characters thrown by an explosion fly along arcs and tumble in the air" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Contents"><p class="post-toc-title">Contents</p><ol><li><a href="#why-not-a-real-ragdoll" data-ga="blog_toc_clicked" data-ga-heading-id="why-not-a-real-ragdoll">Why not a real ragdoll</a></li><li><a href="#the-body-as-a-single-point" data-ga="blog_toc_clicked" data-ga-heading-id="the-body-as-a-single-point">The body as a single point</a></li><li><a href="#phases-of-a-knocked-down-enemy" data-ga="blog_toc_clicked" data-ga-heading-id="phases-of-a-knocked-down-enemy">Phases of a knocked-down enemy</a></li><li><a href="#launch-higher-not-farther" data-ga="blog_toc_clicked" data-ga-heading-id="launch-higher-not-farther">Launch: higher, not farther</a></li><li><a href="#landing-on-a-walkable-cell" data-ga="blog_toc_clicked" data-ga-heading-id="landing-on-a-walkable-cell">Landing on a walkable cell</a><ol><li><a href="#bouncing-off-walls-in-the-air-is-not-enough" data-ga="blog_toc_clicked" data-ga-heading-id="bouncing-off-walls-in-the-air-is-not-enough">Bouncing off walls in the air is not enough</a></li><li><a href="#trajectory-prediction-at-launch" data-ga="blog_toc_clicked" data-ga-heading-id="trajectory-prediction-at-launch">Trajectory prediction at launch</a></li></ol></li><li><a href="#rotation-planned-from-the-flight-height" data-ga="blog_toc_clicked" data-ga-heading-id="rotation-planned-from-the-flight-height">Rotation planned from the flight height</a></li><li><a href="#putting-the-model-on-the-ground" data-ga="blog_toc_clicked" data-ga-heading-id="putting-the-model-on-the-ground">Putting the model on the ground</a></li><li><a href="#dead-enemies-body-decal-and-dust" data-ga="blog_toc_clicked" data-ga-heading-id="dead-enemies-body-decal-and-dust">Dead enemies: body, decal and dust</a></li><li><a href="#traps-in-unity-entities" data-ga="blog_toc_clicked" data-ga-heading-id="traps-in-unity-entities">Traps in Unity Entities</a></li><li><a href="#performance" data-ga="blog_toc_clicked" data-ga-heading-id="performance">Performance</a></li><li><a href="#limits-of-the-solution" data-ga="blog_toc_clicked" data-ga-heading-id="limits-of-the-solution">Limits of the solution</a></li></ol></nav><p>When an explosion throws hundreds of enemies at once, a real ragdoll for each of them is too expensive. You build a fake ragdoll in Unity DOTS by simulating the whole enemy body as a single point with its own simple physics and only rotating the model around the body&#39;s center. Falling and getting up are ordinary animation clips. No bones are simulated at all.</p>
<p>We show the approach on a PC tower defense game we are building in Unity with ECS (<a href="https://smallwater.eu/en/#referencie">more in our references</a>). In that game, a bomb or a fireball knocks back dozens to hundreds of enemies at once. With 2,000 bodies in the air, the simulation costs 0.53 to 0.59 ms per tick (a tick is one fixed-length step of the game simulation).</p>
<h2 id="why-not-a-real-ragdoll"><a class="heading-anchor" href="#why-not-a-real-ragdoll" aria-hidden="true" tabindex="-1">#</a>Why not a real ragdoll</h2>
<p>A real ragdoll means ten or more physics bodies connected by joints per character. With thousands of characters on screen, that is not realistic.</p>
<p>The second reason is determinism: the same state has to produce the same result. Our game relies on it to replay a lost wave from a checkpoint, and we check it with a hash of the simulation state after 1,800 ticks.</p>
<h2 id="the-body-as-a-single-point"><a class="heading-anchor" href="#the-body-as-a-single-point" aria-hidden="true" tabindex="-1">#</a>The body as a single point</h2>
<p>Instead of a physics engine, use your own simple 2.5D physics: a position on the grid, height above the ground, ballistic flight, bouncing off walls and friction. In our game the bomb moves this way too, and an enemy hit by an explosion briefly becomes the same kind of body. The inspiration was the way soldiers fly in <a href="https://store.steampowered.com/app/1272320/Diplomacy_is_Not_an_Option/" target="_blank" rel="noopener">Diplomacy is Not an Option</a>.</p>
<p>The simulation only knows position, height and rotation angle. Don&#39;t write the model&#39;s rotation into it. It belongs in the entity&#39;s additional transform matrix, which the Entities package (ECS in Unity) supports for non-uniform scale (<a href="https://docs.unity3d.com/Packages/com.unity.entities@1.3/api/Unity.Transforms.PostTransformMatrix.html" target="_blank" rel="noopener">documentation</a>).</p>
<h2 id="phases-of-a-knocked-down-enemy"><a class="heading-anchor" href="#phases-of-a-knocked-down-enemy" aria-hidden="true" tabindex="-1">#</a>Phases of a knocked-down enemy</h2>
<p>A knocked-down enemy goes through three phases and then returns to the crowd:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">on hit:
    compute launch velocity (force, distance from the explosion, randomness from the seed)
    find a landing on a walkable cell (see below)
    enable the &quot;knocked&quot; state, release the spot it held at the attack target
flight:     the body flies along a parabola, the model rotates around the body center
landing:    pose from the rotation (back or belly), short settle
lying:      waits a fixed number of ticks
getting up: get-up clip from the back or from the belly, then back to the crowd</code></pre>
<p>Make the &quot;knocked&quot; state a component that can be switched on and off without moving the entity between chunks (the memory blocks in which ECS keeps entities with the same set of components). Navigation, crowd separation and attack decisions then simply skip knocked-down enemies. How navigation and separation work for a large crowd is covered in the article on <a href="https://smallwater.eu/en/blog/unity-dots-horde-navigation/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/fake-ragdoll-unity-dots/" data-ga-destination="/en/blog/unity-dots-horde-navigation/" data-ga-placement="inline">horde navigation in Unity DOTS</a>.</p>
<h2 id="launch-higher-not-farther"><a class="heading-anchor" href="#launch-higher-not-farther" aria-hidden="true" tabindex="-1">#</a>Launch: higher, not farther</h2>
<p>The simplest launch uses an angle of 45° ± 10°. With a bomb force of 18 m/s and gravity of 20 m/s², the apex comes out at around 4 m. The body flies low over the crowd and the effect gets lost in it.</p>
<p>More force doesn&#39;t help, because the range grows with it. Split the velocity into two components and scale each one separately: an angle of 60° ± 5°, horizontal component × 0.85, vertical × 1.3. The apex is then around 10 m, the range stays at around 15.5 m and the flight takes roughly 2 seconds.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/launch-arcs.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/launch-arcs.png" alt="Side view: a low dashed arc and a high arc over a house with silhouettes of a character rotating during the flight" width="2200" height="1520" loading="lazy" decoding="async" /></picture><figcaption>Dashed: a 45° launch. Solid: a 60° launch with separate scales. The house is 5 m tall.</figcaption></figure>
<p>The direction deviates from the explosion by ± 15°, the force by ± 20%, and at the edge of the explosion the force drops to half. Don&#39;t take randomness from a global generator. Derive it from a hash of the level seed, the enemy&#39;s identifier and the explosion&#39;s sequence number. Process knockdown requests in sorted order, because jobs running on several threads produce them in arbitrary order. Two runs of the same wave then give the same arcs.</p>
<p>Knockdown is yes or no. Every knockable enemy at least falls when hit (the speed has a lower bound of 2 m/s), and resistance only changes the range. Give immunity its own flag; in our game the largest enemy type has it. If immunity came from resistance (at 0.95 the speed falls below 2 m/s) and weak knockdowns were cancelled, the enemy would only be immune by accident.</p>
<h2 id="landing-on-a-walkable-cell"><a class="heading-anchor" href="#landing-on-a-walkable-cell" aria-hidden="true" tabindex="-1">#</a>Landing on a walkable cell</h2>
<p>An enemy must not land in a ravine, on a cliff or inside a tower&#39;s footprint. Walkability is defined per grid cell and separately for each enemy size class.</p>
<h3 id="bouncing-off-walls-in-the-air-is-not-enough"><a class="heading-anchor" href="#bouncing-off-walls-in-the-air-is-not-enough" aria-hidden="true" tabindex="-1">#</a>Bouncing off walls in the air is not enough</h3>
<p>The obvious idea is to treat non-walkable cells as walls the flying body bounces off. Next to a cliff that looks natural, next to a ravine it doesn&#39;t: above a ravine there is only air, but the cell is non-walkable, so the body bounces off an invisible column above its edge. On top of that, nothing guarantees that it ends up in a good spot after the bounce.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/dead-end-bounce.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/dead-end-bounce.png" alt="Side view: a body flies over a ravine, bounces mid-flight off an invisible column above its edge and falls back" width="2200" height="1640" loading="lazy" decoding="async" /></picture><figcaption>Bouncing off a non-walkable cell. The body bounces off the air above the edge of the ravine.</figcaption></figure>
<h3 id="trajectory-prediction-at-launch"><a class="heading-anchor" href="#trajectory-prediction-at-launch" aria-hidden="true" tabindex="-1">#</a>Trajectory prediction at launch</h3>
<p>It is more reliable to find the landing right at launch. Simulate the flight ahead with the same step as the real physics and check the landing cell for the enemy&#39;s size class. If it isn&#39;t walkable, try other variants in a fixed order:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">for angle in [0, +25, -25, +50, -50, ... , +175, -175]:
    for horizontal speed multiplier in [1, 0.7, 0.4, 0.1]:
        simulate the flight
        if the landing cell is walkable for the enemy&#x27;s class: use this direction and stop
if nothing worked: horizontal speed = 0 (hop in place)</code></pre>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/fit-search.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/fit-search.png" alt="Top view of a grid with a ravine and a raised cliff, crossed-out rejected landings and the first walkable one highlighted" width="2200" height="1400" loading="lazy" decoding="async" /></picture><figcaption>Explosion, enemy, rejected landings and the chosen one. The grey dots are all candidate landings.</figcaption></figure>
<p>The worst case is 60 flight simulations per enemy (15 directions × 4 multipliers). The vertical speed doesn&#39;t change, so the flight height stays the same and only the range gets shorter. If another explosion hits the enemy during the flight, run the prediction again.</p>
<figure class="post-figure post-figure-video"><video class="post-video" muted loop playsinline controls preload="none" poster="https://smallwater.eu/blog/fake-ragdoll-unity-dots/ragdoll-loop.jpg" width="1280" height="720" data-lazy-video aria-label="A 2D illustration: two enemies would land in the ravine (crosses), the prediction shortens their flight."><source src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/ragdoll-loop.mp4" type="video/mp4" /></video><figcaption>A 2D illustration: two enemies would land in the ravine (crosses), the prediction shortens their flight.</figcaption></figure>
<p>To keep the body from sliding or bouncing after landing, give it a ground restitution of 0 and high friction. It stays where the prediction said it would.</p>
<h2 id="rotation-planned-from-the-flight-height"><a class="heading-anchor" href="#rotation-planned-from-the-flight-height" aria-hidden="true" tabindex="-1">#</a>Rotation planned from the flight height</h2>
<p>The simplest option is to spin the model at a random speed, for example 180 to 540 °/s, and settle it onto the nearest lying pose within 0.2 s after landing. The settle is visible, though, when the body lands almost upright. Adjusting the rotation speed every tick so that the angle comes out exactly horizontal at landing only helps partly: even a low throw spins like a somersault, and the rotation has nothing to do with how high the body flies.</p>
<p>It works better to plan the rotation at launch from the flight height:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">height = vz² / (2 g), flight time from vz and height
target = nearest horizontal pose in a random direction (quarter turn from standing)
target += half turn for every 2.5 m of height, at most 3
rotation speed = (target - current angle) / flight time</code></pre>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/tumble-plan.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/tumble-plan.png" alt="Two arcs: a low throw where the character only falls over, and a high one where it makes three and a quarter turns" width="2200" height="1200" loading="lazy" decoding="async" /></picture><figcaption>A low throw (apex around 2 m) rotates by 90°, a bomb (around 10 m) by 90° plus three half turns.</figcaption></figure>
<p>The landing side follows from the angle: face to the ground means belly, otherwise back. It selects the get-up clip. An enemy that is lying or getting up keeps its current angle when hit again and doesn&#39;t snap back to upright.</p>
<h2 id="putting-the-model-on-the-ground"><a class="heading-anchor" href="#putting-the-model-on-the-ground" aria-hidden="true" tabindex="-1">#</a>Putting the model on the ground</h2>
<p>The model rotates around the body&#39;s center, but the ground is under its lowest point, and that point changes with the rotation. Estimating the offset from two constants and the sine of the angle doesn&#39;t work for models with arms spread wide in the fall clip: in our game the skeleton model (a common enemy) sank up to 1.15 m into the ground and the necromancer model up to 1.73 m.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Estimate</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/ground-before.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/ground-before.png" alt="Six rotations of a character, the parts below the ground are red" width="2200" height="840" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Measured</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/ground-after.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/ground-after.png" alt="Six rotations of a character, each touches the ground with its lowest point" width="2200" height="840" loading="lazy" decoding="async" /></picture></div></div><figcaption>The same rotations with the offset from constants and from the measured table.</figcaption></figure>
<p>It is more reliable to measure the lowest point. When baking the animations, compute the body&#39;s lowest point for the fall clip at 72 rotations in 5° steps, and interpolate from the table at runtime. The deepest sinking then drops to 4 mm. A single minimum across all frames of the clip is not enough, though: the body would hover 0.15 to 0.28 m above the ground after landing until the get-up clip pulls it down. So the table has to be frames × rotations.</p>
<p>The slow planned rotation reveals one more effect. If you place the model with its lowest point at the flight height, the body&#39;s center drops by the difference between half the character&#39;s height and half its thickness as it goes from standing to horizontal. The body then rises only slowly at the start of the flight and speeds up afterwards. Lift the model by the difference between its half extent when standing and at the current angle, at most by the flight height. The body&#39;s center then follows the parabola.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/flight-lift.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/flight-lift.png" alt="Chart of the body center&#39;s height over time: a dashed ideal parabola, a red curve with a dip and a blue one that follows the parabola" width="2200" height="1040" loading="lazy" decoding="async" /></picture><figcaption>Height of the body center during the flight: without the lift (red) and with it (blue).</figcaption></figure>
<h2 id="dead-enemies-body-decal-and-dust"><a class="heading-anchor" href="#dead-enemies-body-decal-and-dust" aria-hidden="true" tabindex="-1">#</a>Dead enemies: body, decal and dust</h2>
<p>An enemy killed by an explosion should fly too. Create a flying body for it: a copy of the entity without gameplay components (health, reward, target). The body flies, lands, lies for a while and disappears. A corpse decal (a texture projected onto the ground) as in Warcraft 3 and a puff of dust stay in its place. In our game the limits are 1,000 flying bodies and 1,500 decals.</p>
<p>The decal is a top-down view of a frame from the fall clip, rotated the same way as the lying body, so the swap from body to decal isn&#39;t visible. The first frame of the get-up clip doesn&#39;t work, because the character is already sitting up from its back. Merge dust puffs within a 1.5 m cell. A bomb into a crowd then creates a few larger clouds instead of hundreds of small ones.</p>
<h2 id="traps-in-unity-entities"><a class="heading-anchor" href="#traps-in-unity-entities" aria-hidden="true" tabindex="-1">#</a>Traps in Unity Entities</h2>
<ul>
<li><strong>The matrix from the baker disappears.</strong> If you add the additional matrix at bake time, the transform system removes it at uniform scale and knocked enemies don&#39;t move. Request non-uniform scale when baking. The prefab&#39;s whole scale then moves into that same matrix, so don&#39;t overwrite it with the rotation alone. Otherwise a knocked enemy scaled 2× ends up half its size, even after getting up. Store the scale separately and build the matrix from scale and rotation.</li>
<li><strong>Root height when importing clips.</strong> With humanoid clips, the root&#39;s vertical motion is thrown away and characters fall through the floor when getting up. Bake the root height into the pose. Don&#39;t use the option to anchor the pose to the feet: it moves the whole body a meter down.</li>
<li><strong>Matrix in closed form.</strong> Translation, rotation and reverse translation as three matrix multiplications are measurably slower in a job than a hand-written formula for the resulting matrix.</li>
<li><strong>Burst running an old version.</strong> After you change the fields of a component read by a Burst job, the editor may crash or report nonsense. Restarting the editor fixes it.</li>
</ul>
<h2 id="performance"><a class="heading-anchor" href="#performance" aria-hidden="true" tabindex="-1">#</a>Performance</h2>
<p>Measured in the Unity Editor on an M3 Pro, 10,000 skeletons in a benchmark scene, launch force 18. The times are the sum of the Burst jobs&#39; CPU time across all threads, not frame time. The chart and the table are without trajectory prediction and landing tables.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/bench.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/bench.png" alt="Chart of time per tick by the number of knocked enemies for the simulation and visual preparation, with a 0.5 ms target line" width="2200" height="1120" loading="lazy" decoding="async" /></picture><figcaption>Simulation and visual preparation without trajectory prediction. Target 0.5 ms at 2,000 knocked.</figcaption></figure>
<div class="table-wrap"><table>
<thead>
<tr>
<th>knocked</th>
<th>simulation (ms/tick)</th>
<th>visual preparation (ms)</th>
</tr>
</thead>
<tbody><tr>
<td>500</td>
<td>0.28 to 0.30</td>
<td>0.15</td>
</tr>
<tr>
<td>2,000</td>
<td>0.53</td>
<td>0.30 to 0.55</td>
</tr>
<tr>
<td>5,000</td>
<td>0.74 to 0.75</td>
<td>0.49 to 0.91</td>
</tr>
<tr>
<td>10,000</td>
<td>0.96 to 1.01</td>
<td>0.68 to 1.24</td>
</tr>
</tbody></table>
</div>
<ul>
<li>The simulation grows linearly, roughly 0.25 ms per thousand knocked. With trajectory prediction and landing tables it is 0.58 to 0.59 ms at 2,000 knocked, slightly above the 0.5 ms target. A sensible cap is therefore 3,000 enemies knocked at once, which is about 0.8 ms.</li>
<li>An explosion into a group of 359 enemies next to a wall costs 1.3 to 1.9 ms in the knockdown request system in a single frame. Most of that is the trajectory prediction.</li>
<li>Editor FPS can&#39;t be compared: two runs of the same configuration differed by up to 80 FPS.</li>
</ul>
<h2 id="limits-of-the-solution"><a class="heading-anchor" href="#limits-of-the-solution" aria-hidden="true" tabindex="-1">#</a>Limits of the solution</h2>
<p>Visual preparation went up to 1.3 ms in another test scene, probably because of reading the landing table for every body. It is the first candidate for optimization. FPS in a build and the load of killing 1,000 enemies at once (target: at most 1.5 ms extra per frame) are not measured yet.</p>
<p>If you are working on a similar crowd or effects simulation in Unity and need help, <a href="https://smallwater.eu/en/#kontakt">get in touch</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to build a shmup for Apple Watch</title>
      <link>https://smallwater.eu/en/blog/shmup-apple-watch-devlog/</link>
      <guid isPermaLink="true">https://smallwater.eu/en/blog/shmup-apple-watch-devlog/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Space Impact Watch</category>
      <description>A guide to building a shmup for Apple Watch: virtual resolution, Digital Crown controls, rectangle collisions, pooling, timed level tables, a wave generator.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/cover.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/cover.jpg" alt="Pixel scene on a green display: a ship on the left shooting at comets, rockets and a boss on the right" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Contents"><p class="post-toc-title">Contents</p><ol><li><a href="#a-fixed-84-48-virtual-resolution" data-ga="blog_toc_clicked" data-ga-heading-id="a-fixed-84-48-virtual-resolution">A fixed 84 × 48 virtual resolution</a></li><li><a href="#crown-controls-and-automatic-fire" data-ga="blog_toc_clicked" data-ga-heading-id="crown-controls-and-automatic-fire">Crown controls and automatic fire</a></li><li><a href="#a-game-loop-in-a-single-scene" data-ga="blog_toc_clicked" data-ga-heading-id="a-game-loop-in-a-single-scene">A game loop in a single scene</a></li><li><a href="#rectangle-collisions-instead-of-a-physics-engine" data-ga="blog_toc_clicked" data-ga-heading-id="rectangle-collisions-instead-of-a-physics-engine">Rectangle collisions instead of a physics engine</a></li><li><a href="#pooling-only-where-it-pays-off" data-ga="blog_toc_clicked" data-ga-heading-id="pooling-only-where-it-pays-off">Pooling only where it pays off</a></li><li><a href="#levels-as-timed-tables" data-ga="blog_toc_clicked" data-ga-heading-id="levels-as-timed-tables">Levels as timed tables</a></li><li><a href="#endless-mode-from-one-pseudo-random-number" data-ga="blog_toc_clicked" data-ga-heading-id="endless-mode-from-one-pseudo-random-number">Endless mode from one pseudo-random number</a></li><li><a href="#limits-of-this-approach" data-ga="blog_toc_clicked" data-ga-heading-id="limits-of-this-approach">Limits of this approach</a></li></ol></nav><p>A shmup (shoot &#39;em up, a shooter where your ship flies against waves of enemies) on Apple Watch runs into three problems: a tiny display, a finger that covers half the game, and watches that come in different sizes. Three decisions solve them: the whole game runs on a fixed 84 × 48 grid, collisions are checked by hand with rectangles, and levels are tables of timestamps rather than code. The ship is steered with the Digital Crown and fires on its own.</p>
<p>The examples come from <a href="https://smallwater.eu/space-impact-watch">Space Impact Watch</a>, a remake of Space Impact, the shooter that shipped on the Nokia 3310 in 2000. It runs on the watch as well as on iPhone and iPad. The code is built on SpriteKit, Apple&#39;s 2D game framework, but the principles apply to any shmup for a small screen.</p>
<h2 id="a-fixed-84-48-virtual-resolution"><a class="heading-anchor" href="#a-fixed-84-48-virtual-resolution" aria-hidden="true" tabindex="-1">#</a>A fixed 84 × 48 virtual resolution</h2>
<p>First decide which units the game works in. If you use screen points, the world has a different size on every watch. It is better to pick a small virtual resolution and do everything in it. Our game uses 84 × 48, exactly the size of the <a href="https://www.adafruit.com/products/338" target="_blank" rel="noopener">Nokia 3310 display</a>. Positions, speeds, sprite sizes and enemy paths are all in these units. When the scene starts, it calculates once how many screen points one virtual pixel spans (scene width divided by 84), and everything is simply multiplied by that.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/grid.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/grid.png" alt="Game scene with a visible grid of 84 × 48 virtual pixels" width="1008" height="576" loading="lazy" decoding="async" /></picture><figcaption>A level on the 84 × 48 grid. Each square is one virtual pixel, which the watch draws as a block of several real points.</figcaption></figure>
<p>What you get:</p>
<ul>
<li><strong>The same look on every device.</strong> The game looks identical on every watch size and on the iPad; only the block size changes.</li>
<li><strong>Level design in small whole numbers.</strong> An enemy flies at height 13, a rocket turns at x = 20.</li>
<li><strong>Simple graphics.</strong> Sprites are single-colour and drawn at ten times their size, so one virtual pixel is a 10 × 10 block in the source image. They get their colour in the game: black on a green background in light levels, the other way round in dark ones.</li>
</ul>
<h2 id="crown-controls-and-automatic-fire"><a class="heading-anchor" href="#crown-controls-and-automatic-fire" aria-hidden="true" tabindex="-1">#</a>Crown controls and automatic fire</h2>
<p>On a watch, a finger covers half the game, so the ship is not steered by touch. The Digital Crown handles movement: turning it moves the ship up or down. Add a sensitivity setting and an option to invert the direction.</p>
<p>Firing is automatic; in our game every 0.4 seconds, and every 0.3 from level four on. The display is used for a single gesture: a swipe to the right fires one of three special weapons.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/weapons.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/weapons.png" alt="Three special weapons: a vertical line, a homing missile and a laser hitting a boss" width="1104" height="240" loading="lazy" decoding="async" /></picture><figcaption>Three special weapons: a line that covers the full height of the playfield, a homing missile and a laser.</figcaption></figure>
<p>On a watch, a vibration on hit matters as much as the animation; a small explosion on the display is easy to miss. Trigger haptics through a single interface (hit, enemy killed, boss defeated) that each platform implements separately: the watch answers with a vibration, the iPhone in its own way. The phone version has no crown; it has on-screen buttons instead, using the same sensitivity and direction settings.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/watch-2021.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/watch-2021.png" alt="Screenshot of the game in the Apple Watch simulator, the ship facing a boss" width="368" height="448" loading="lazy" decoding="async" /></picture><figcaption>The game in the Apple Watch simulator: the ship facing a boss.</figcaption></figure>
<h2 id="a-game-loop-in-a-single-scene"><a class="heading-anchor" href="#a-game-loop-in-a-single-scene" aria-hidden="true" tabindex="-1">#</a>A game loop in a single scene</h2>
<p>The whole game can be one scene with one loop:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">every frame:
    move all objects
    check collisions
    spawn new enemies based on the time in the level</code></pre>
<p>Add features in the order that lets you test as early as possible:</p>
<ul>
<li><strong>Shooting and player movement.</strong> Nothing can be tested without them.</li>
<li><strong>Enemies and collisions.</strong> The first enemy only needs to fly straight left and die after one hit.</li>
<li><strong>Vibration and hit animations.</strong> On a small display, the player cannot tell a hit landed without them.</li>
<li><strong>Background and HUD.</strong> Lives, score, number of special weapons. If the HUD gets in the way on some level, move it to the bottom.</li>
</ul>
<p>At the end you have a playable loop with lives, score, explosions, a background and player respawn. If you keep the game logic separate from rendering and input, the watch and phone versions can share it.</p>
<h2 id="rectangle-collisions-instead-of-a-physics-engine"><a class="heading-anchor" href="#rectangle-collisions-instead-of-a-physics-engine" aria-hidden="true" tabindex="-1">#</a>Rectangle collisions instead of a physics engine</h2>
<p>SpriteKit has a physics engine, and it is tempting to use it for collisions too. Every object gets a physics body built from the outline of its sprite, precise collision detection and a category (player, enemy, bullet, special weapon), and the engine reports the pairs that touched on its own. Apple&#39;s <a href="https://developer.apple.com/documentation/spritekit/getting-started-with-physics-bodies" target="_blank" rel="noopener">documentation on physics bodies</a> says that bodies built from a texture&#39;s alpha channel are the most accurate and also the most expensive to compute. We tried this in our game and went back to checking collisions by hand.</p>
<p>The manual check is one function that goes through pairs of groups and compares rectangles:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">for every pair of groups:
    (player and enemies, player and bullets, bullets and enemies, bullets and bullets)
    for every object A in the first group and object B in the second:
        if rectangle A overlaps rectangle B:
            resolve the hit</code></pre>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/hitbox.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/hitbox.png" alt="Two pairs of sprites with their collision rectangles marked; on the left only the corners of the rectangles overlap" width="960" height="352" loading="lazy" decoding="async" /></picture><figcaption>Left: the rectangles overlap, the pixels do not, and the game counts it as a hit. Right: a real hit.</figcaption></figure>
<p>The benefits: you control the order of the checks, and the whole &quot;what happens on a hit&quot; rule lives in one place. The inaccuracy is small, because on an 84 × 48 grid the sprites are so tiny that the rectangle and the real outline differ by only a few virtual pixels.</p>
<h2 id="pooling-only-where-it-pays-off"><a class="heading-anchor" href="#pooling-only-where-it-pays-off" aria-hidden="true" tabindex="-1">#</a>Pooling only where it pays off</h2>
<p>Pooling means that when an object dies you do not delete it, you hide it and reuse it next time. It is tempting to pool everything, explosions and power-ups included. That has a cost: every collision and every update first has to check whether the object is alive at all. One forgotten check and a hidden enemy kills the player, or a hidden bullet shoots down a rocket. When we removed pooling from everything in our game, most of the deleted code was these checks.</p>
<p>A simple rule: pool what there is a lot of and what is always created the same way. The player creates 21 bullets at the start and then only recycles them. Enemies and their bullets are reused too; explosions and power-ups are created and destroyed normally.</p>
<h2 id="levels-as-timed-tables"><a class="heading-anchor" href="#levels-as-timed-tables" aria-hidden="true" tabindex="-1">#</a>Levels as timed tables</h2>
<p>A level does not have to be code; a list of records is enough. Each record says what appears, at which second, at what height and how it moves. Movement is a list of points the enemy flies through at constant speed. Optionally there are shot times, a boss charge attack or a spawner of smaller enemies.</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">every frame:
    time = now - level start - time spent paused
    for every record in the level table:
        if the record is unused and time &gt; record.time:
            create an object from the record
            mark the record as used</code></pre>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/level1.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/level1.png" alt="Timeline of the first level: seconds from left to right, icon height matches the height at which the enemy appears" width="1004" height="120" loading="lazy" decoding="async" /></picture><figcaption>Level one: 39 records over 67 seconds. Comets, then waves of rockets, power-ups (red) and a boss at the end.</figcaption></figure>
<p>A level written this way is easy to tune: moving a wave by half a second means changing one number. The timeline also shows the rhythm you are after: a short wave, a pause, a different enemy type, a power-up before a harder stretch.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/path.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/path.png" alt="A rocket flying a zigzag across the playfield, with the turning points of its path marked" width="1008" height="576" loading="lazy" decoding="async" /></picture><figcaption>A rocket&#39;s path from level one: six points it flies through at constant speed.</figcaption></figure>
<p>The downside shows with bigger changes. The densest level in our game has 71 rows, and a table like that is hard to read without an editor. It works better to write levels as waves (a group of enemies sharing one movement pattern) rather than as individual ships.</p>
<h2 id="endless-mode-from-one-pseudo-random-number"><a class="heading-anchor" href="#endless-mode-from-one-pseudo-random-number" aria-hidden="true" tabindex="-1">#</a>Endless mode from one pseudo-random number</h2>
<p>A leaderboard (Game Center in our case) needs a mode that never ends. There, a generator replaces the hand-written table and builds it while you play. It produces a 20-digit pseudo-random number, and individual pairs of digits decide one wave:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">number = next pseudo-random 20-digit number
type = power-up if digits 4 to 5 &gt; 90, otherwise enemy
sprite = digits 1 to 2 modulo number of enemy kinds
pause before the wave = digit 3 / 2 seconds (at least 1)
height = digit 7, scaled to the playfield height
movement pattern = digits 10 to 11 modulo 10
ships in the wave = digit 17 / 2 + 1
speed = digit 13 × (1 + wave number / 10), clamped to 20 to 50
every thirtieth wave = boss, generation stops while the boss is alive</code></pre>
<p>Keep the generator&#39;s seed (starting value) fixed in the game. Every player then gets the same sequence of waves, and leaderboard scores are comparable.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/patterns.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/patterns.png" alt="Ten small playfields, each with one enemy movement pattern drawn as a dashed line" width="912" height="228" loading="lazy" decoding="async" /></picture><figcaption>Ten movement patterns from the generator. The exact shape depends on the height at which the wave appears.</figcaption></figure>
<p>A simulation of the generator outside the game (300 waves, the same seed as in the game) shows how the mode develops. The first boss comes after 29 waves, that is after 74 enemies and 3 power-ups, at roughly 130 seconds. The first 30 waves fly at the minimum speed of 20, because a single digit is still below the lower limit. Average speed then rises to 28.5, 37.5 and 41; from wave 120 on, most enemies are at the maximum. Roughly every eleventh wave is a power-up.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/ranked.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/ranked.png" alt="Timeline of the first 135 seconds of endless mode with enemy icons and a boss at the end" width="1104" height="120" loading="lazy" decoding="async" /></picture><figcaption>The first 135 seconds of endless mode according to the generator simulation. Waves are longer and less regular than in a hand-written level.</figcaption></figure>
<p>The weakness is that the generator knows nothing about rhythm. A hand-written level alternates pressure and rest; the generator does not. The pause during bosses and the rising speed help a bit, but the hand-written levels play better.</p>
<h2 id="limits-of-this-approach"><a class="heading-anchor" href="#limits-of-this-approach" aria-hidden="true" tabindex="-1">#</a>Limits of this approach</h2>
<ul>
<li><strong>No automated tests.</strong> Everything is checked by hand in the simulator and on the watch.</li>
<li><strong>Global state.</strong> Most of the game state is global variables of a single scene. That is convenient for a watch-only game and inconvenient once you add an iPhone version.</li>
<li><strong>No level editor.</strong> A timed table is easy to tune in small steps, but bigger changes would call for an editor.</li>
</ul>
<p>If you are making a game for a watch or another small screen and want help with something, <a href="https://smallwater.eu/en/#kontakt">get in touch</a>. How we generate puzzles in another of our apps is covered in the post about our <a href="https://smallwater.eu/en/blog/sudoku-generator/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/shmup-apple-watch-devlog/" data-ga-destination="/en/blog/sudoku-generator/" data-ga-placement="inline">sudoku generator</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to generate a sudoku with a unique solution</title>
      <link>https://smallwater.eu/en/blog/sudoku-generator/</link>
      <guid isPermaLink="true">https://smallwater.eu/en/blog/sudoku-generator/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Crosswords &amp; Puzzles</category>
      <description>A sudoku generator step by step: a fast solver with bit masks, a random full grid, removing cells while checking for a unique solution, and difficulty.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/sudoku-generator/cover.webp" /><img src="https://smallwater.eu/blog/sudoku-generator/cover.jpg" alt="A sudoku in progress with small candidate digits and a highlighted cell that has a single candidate" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Contents"><p class="post-toc-title">Contents</p><ol><li><a href="#why-exactly-one-solution" data-ga="blog_toc_clicked" data-ga-heading-id="why-exactly-one-solution">Why exactly one solution</a></li><li><a href="#step-1-write-a-fast-solver" data-ga="blog_toc_clicked" data-ga-heading-id="step-1-write-a-fast-solver">Step 1: Write a fast solver</a></li><li><a href="#step-2-fill-the-whole-grid" data-ga="blog_toc_clicked" data-ga-heading-id="step-2-fill-the-whole-grid">Step 2: Fill the whole grid</a></li><li><a href="#why-removing-random-cells-is-not-enough" data-ga="blog_toc_clicked" data-ga-heading-id="why-removing-random-cells-is-not-enough">Why removing random cells is not enough</a></li><li><a href="#step-3-remove-cells-while-checking-uniqueness" data-ga="blog_toc_clicked" data-ga-heading-id="step-3-remove-cells-while-checking-uniqueness">Step 3: Remove cells while checking uniqueness</a></li><li><a href="#step-4-set-difficulty-by-the-number-of-empty-cells" data-ga="blog_toc_clicked" data-ga-heading-id="step-4-set-difficulty-by-the-number-of-empty-cells">Step 4: Set difficulty by the number of empty cells</a></li><li><a href="#generator-speed-and-reliability" data-ga="blog_toc_clicked" data-ga-heading-id="generator-speed-and-reliability">Generator speed and reliability</a></li><li><a href="#where-the-number-of-empty-cells-falls-short" data-ga="blog_toc_clicked" data-ga-heading-id="where-the-number-of-empty-cells-falls-short">Where the number of empty cells falls short</a></li></ol></nav><p>You generate a sudoku with a unique solution in two steps. First fill an empty 9×9 grid completely, by backtracking with digits tried in random order. Then remove cells one at a time in random order, and after each removal use a solver to check that the puzzle still has exactly one solution. If it doesn&#39;t, put the digit back. On an ordinary CPU this takes less than a millisecond at the median.</p>
<p>The approach fits any game or app that needs an unlimited supply of fresh puzzles without preparing them by hand. One example from practice is the sudoku in our crosswords and puzzles app (<a href="https://smallwater.eu/crosswords-ios">App Store</a>, <a href="https://smallwater.eu/crosswords-android">Google Play</a>).</p>
<h2 id="why-exactly-one-solution"><a class="heading-anchor" href="#why-exactly-one-solution" aria-hidden="true" tabindex="-1">#</a>Why exactly one solution</h2>
<p>Everyone knows the rules: every row, column and 3×3 box contains each digit from 1 to 9 exactly once. Less often mentioned is that a good puzzle has a <strong>single</strong> solution. Only then can it be solved by pure logic, without guessing.</p>
<p>It has a practical side too. A game usually stores one solution and uses it to check the result and give hints. If a puzzle has two solutions, the player can reach the other one, which is just as correct, and the game won&#39;t accept it.</p>
<h2 id="step-1-write-a-fast-solver"><a class="heading-anchor" href="#step-1-write-a-fast-solver" aria-hidden="true" tabindex="-1">#</a>Step 1: Write a fast solver</h2>
<p>A solver is a program that solves the sudoku. It works by backtracking: it fills a cell with one of the possible digits, moves on to the next cell, and when it hits a contradiction, it goes back and tries another digit. You need it twice: to fill the empty grid and to check uniqueness. The check runs after every removed cell, so 40 to 70 times per puzzle, which means it has to be fast. Two things matter:</p>
<ul>
<li><strong>Bit masks.</strong> Keep a 9-bit number of used digits for every row, column and box. A cell&#39;s candidates, the digits that can still go into it, then take a single operation: all digits except those already in its row, column or box. The number of candidates is the number of set bits and can be precomputed into a table of 512 values.</li>
<li><strong>Cell with the fewest candidates first.</strong> Don&#39;t take the first empty cell from the left, take the one with the fewest options (known in the literature as the MRV heuristic, minimum remaining values). Fill a cell with a single candidate right away. A cell with no candidate means a dead branch and you backtrack.</li>
</ul>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/sudoku-generator/candidates.webp" /><img src="https://smallwater.eu/blog/sudoku-generator/candidates.png" alt="Grid in progress with small candidate digits in the empty cells, one cell with a single candidate highlighted" width="1024" height="1034" loading="lazy" decoding="async" /></picture><figcaption>Candidates in the empty cells. The highlighted cell has a single candidate, so the solver fills it first.</figcaption></figure>
<p>On hard puzzles, the uniqueness check with this rule needs 311 steps at the median. If the solver takes cells left to right instead, it needs 28,579 at the median and more than 300,000 in the worst 5% of cases. That is roughly a 90-fold difference.</p>
<h2 id="step-2-fill-the-whole-grid"><a class="heading-anchor" href="#step-2-fill-the-whole-grid" aria-hidden="true" tabindex="-1">#</a>Step 2: Fill the whole grid</h2>
<p>You get a full grid from the same solver run on an empty grid. The only difference is that you try a cell&#39;s candidates in random order. The first solution found is a random, valid, completely filled grid.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/sudoku-generator/solved.webp" /><img src="https://smallwater.eu/blog/sudoku-generator/solved.png" alt="Completely filled 9×9 sudoku grid" width="1024" height="1034" loading="lazy" decoding="async" /></picture><figcaption>A random full grid. This becomes the puzzle.</figcaption></figure>
<h2 id="why-removing-random-cells-is-not-enough"><a class="heading-anchor" href="#why-removing-random-cells-is-not-enough" aria-hidden="true" tabindex="-1">#</a>Why removing random cells is not enough</h2>
<p>The simplest idea is to remove N random cells from the full grid, for example 40, 50 or 55 depending on difficulty. The puzzle looks fine, but it often has more than one solution.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/sudoku-generator/ambiguous.webp" /><img src="https://smallwater.eu/blog/sudoku-generator/ambiguous.png" alt="Sudoku puzzle where four cells in the corners of a rectangle have two possible digit pairs" width="1024" height="1034" loading="lazy" decoding="async" /></picture><figcaption>A puzzle from random removal. Both digit pairs fit in the highlighted cells, so the puzzle has two solutions.</figcaption></figure>
<p>The picture shows the typical case. Four cells in the corners of a rectangle spanning two boxes can be filled in two ways, and nothing in the rest of the grid decides between them. Sudoku solvers know this as a deadly pattern. On 1,000 puzzles per difficulty the result looks like this:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Difficulty</th>
<th align="right">Cells removed</th>
<th align="right">Puzzles with more than one solution</th>
</tr>
</thead>
<tbody><tr>
<td>Easy</td>
<td align="right">40</td>
<td align="right">59%</td>
</tr>
<tr>
<td>Medium</td>
<td align="right">50</td>
<td align="right">96%</td>
</tr>
<tr>
<td>Hard</td>
<td align="right">55</td>
<td align="right">100%</td>
</tr>
</tbody></table>
</div>
<p>A hard puzzle from random removal has hundreds of solutions on average. A uniqueness check after every removal is therefore necessary.</p>
<h2 id="step-3-remove-cells-while-checking-uniqueness"><a class="heading-anchor" href="#step-3-remove-cells-while-checking-uniqueness" aria-hidden="true" tabindex="-1">#</a>Step 3: Remove cells while checking uniqueness</h2>
<p>Go through all 81 cells in random order. Try to empty each one and count the solutions with the solver, but stop counting at two. That is all you need to know: one solution means the cell can stay empty, two mean it has to go back.</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">puzzle = copy of the full grid
empty = 0
for each cell in random order:
    if empty == target: stop
    digit = puzzle[cell]
    puzzle[cell] = blank
    if count_solutions(puzzle, at most 2) == 1:
        empty = empty + 1
    else:
        puzzle[cell] = digit</code></pre>
<p>Once you put a cell back, don&#39;t try it again. Further removals only loosen the puzzle, so a cell that couldn&#39;t be emptied now won&#39;t be removable later either.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/sudoku-generator/dig.webp" /><img src="https://smallwater.eu/blog/sudoku-generator/dig.png" alt="Hard puzzle in progress with 37 empty cells and one highlighted digit that had to be put back" width="1024" height="1034" loading="lazy" decoding="async" /></picture><figcaption>After 37 removed cells. The highlighted digit couldn&#39;t be removed, the puzzle would have two solutions, so it went back.</figcaption></figure>
<h2 id="step-4-set-difficulty-by-the-number-of-empty-cells"><a class="heading-anchor" href="#step-4-set-difficulty-by-the-number-of-empty-cells" aria-hidden="true" tabindex="-1">#</a>Step 4: Set difficulty by the number of empty cells</h2>
<p>The simplest measure of difficulty is the number of removed cells. The three levels we use in our game:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Difficulty</th>
<th align="right">Empty cells</th>
<th align="right">Digits left</th>
<th align="right">Cells put back on average</th>
</tr>
</thead>
<tbody><tr>
<td>Easy</td>
<td align="right">40</td>
<td align="right">41</td>
<td align="right">0.9</td>
</tr>
<tr>
<td>Medium</td>
<td align="right">50</td>
<td align="right">31</td>
<td align="right">4.5</td>
</tr>
<tr>
<td>Hard</td>
<td align="right">55</td>
<td align="right">26</td>
<td align="right">12.7</td>
</tr>
</tbody></table>
</div>
<p>The fewer digits are left, the more often another removal would break uniqueness and the cell has to go back. On the hard level a single pass sometimes stops before reaching 55 empty cells. In that case generate a new full grid and try again. Measured on 1,000 hard puzzles, the first pass was enough in 97.6% of cases, and it never took more than 3 attempts.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/sudoku-generator/final.webp" /><img src="https://smallwater.eu/blog/sudoku-generator/final.png" alt="Finished hard sudoku puzzle with 26 digits" width="1024" height="1034" loading="lazy" decoding="async" /></picture><figcaption>A finished hard puzzle: 26 digits and a single solution.</figcaption></figure>
<h2 id="generator-speed-and-reliability"><a class="heading-anchor" href="#generator-speed-and-reliability" aria-hidden="true" tabindex="-1">#</a>Generator speed and reliability</h2>
<p>We ran the generator on 1,000 puzzles per difficulty (Node.js 22 on a server CPU, a phone will be slower):</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Difficulty</th>
<th align="right">Median time</th>
<th align="right">95% of puzzles within</th>
<th align="right">Longest</th>
<th align="right">Puzzles with several solutions</th>
</tr>
</thead>
<tbody><tr>
<td>Easy</td>
<td align="right">0.13 ms</td>
<td align="right">0.29 ms</td>
<td align="right">3.5 ms</td>
<td align="right">0</td>
</tr>
<tr>
<td>Medium</td>
<td align="right">0.21 ms</td>
<td align="right">0.38 ms</td>
<td align="right">0.8 ms</td>
<td align="right">0</td>
</tr>
<tr>
<td>Hard</td>
<td align="right">0.48 ms</td>
<td align="right">1.39 ms</td>
<td align="right">5.5 ms</td>
<td align="right">0</td>
</tr>
</tbody></table>
</div>
<p>We also checked every generated puzzle independently with the solver. All 3,000 had a single solution.</p>
<h2 id="where-the-number-of-empty-cells-falls-short"><a class="heading-anchor" href="#where-the-number-of-empty-cells-falls-short" aria-hidden="true" tabindex="-1">#</a>Where the number of empty cells falls short</h2>
<p>The clue count is a rough measure of difficulty. Two puzzles with 26 digits can differ in difficulty depending on which logical techniques they require. A more precise rating would solve the puzzle the way a person does and record the hardest technique it had to use. For a mobile game where the player picks one of three levels, the clue count is enough.</p>
<aside class="app-box app-box-callout"><img class="app-box-img" src="https://smallwater.eu/images/crosswords/1-crosswords.jpg" alt="" width="698" height="1516" loading="lazy" decoding="async" /><div class="app-box-body"><p class="app-box-eyebrow">Try it</p><h2 class="app-box-title">Crosswords &amp; Puzzles</h2><p>Crosswords, word search, sudoku and nonograms in one offline app. How the puzzles are generated and what&#39;s new.</p><div class="app-box-links"><a class="btn btn-primary" href="https://smallwater.eu/crosswords-ios" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="crosswords" data-ga-destination="/crosswords-ios" data-ga-app="crosswords" data-ga-store="app_store">App Store</a><a class="btn btn-ghost" href="https://smallwater.eu/crosswords-android" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="crosswords" data-ga-destination="/crosswords-android" data-ga-app="crosswords" data-ga-store="google_play">Google Play</a></div></div></aside>
<p>If you are solving a similar problem, generating puzzles or need a mobile app, <a href="https://smallwater.eu/en/#kontakt">get in touch</a>. How to generate a word search with a hidden message is covered in a <a href="https://smallwater.eu/en/blog/word-search-generator/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/sudoku-generator/" data-ga-destination="/en/blog/word-search-generator/" data-ga-placement="inline">separate tutorial</a>. Arrowwords with a hidden message have <a href="https://smallwater.eu/en/blog/swedish-crossword-generator/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/sudoku-generator/" data-ga-destination="/en/blog/swedish-crossword-generator/" data-ga-placement="inline">their own tutorial</a> too.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to generate an arrowword with a hidden message</title>
      <link>https://smallwater.eu/en/blog/swedish-crossword-generator/</link>
      <guid isPermaLink="true">https://smallwater.eu/en/blog/swedish-crossword-generator/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Crosswords &amp; Puzzles</category>
      <description>A guide to generating Swedish-style crosswords: a hill-climbing mask, word filling as a backtracking CSP, and the hidden message found in the finished grid.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/cover.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/cover.jpg" alt="Two 10×10 arrowword grids: on the left a random mask with penalised cells, on the right a filled grid with the hidden message letters in coral" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Contents"><p class="post-toc-title">Contents</p><ol><li><a href="#what-an-arrowword-has-to-satisfy" data-ga="blog_toc_clicked" data-ga-heading-id="what-an-arrowword-has-to-satisfy">What an arrowword has to satisfy</a></li><li><a href="#why-filling-the-grid-word-by-word-is-not-enough" data-ga="blog_toc_clicked" data-ga-heading-id="why-filling-the-grid-word-by-word-is-not-enough">Why filling the grid word by word is not enough</a></li><li><a href="#step-1-the-mask-with-hill-climbing" data-ga="blog_toc_clicked" data-ga-heading-id="step-1-the-mask-with-hill-climbing">Step 1: the mask with hill climbing</a></li><li><a href="#step-2-filling-with-words-as-a-csp" data-ga="blog_toc_clicked" data-ga-heading-id="step-2-filling-with-words-as-a-csp">Step 2: filling with words as a CSP</a></li><li><a href="#step-3-the-hidden-message-in-the-finished-grid" data-ga="blog_toc_clicked" data-ga-heading-id="step-3-the-hidden-message-in-the-finished-grid">Step 3: the hidden message in the finished grid</a></li><li><a href="#why-not-place-the-message-in-advance-like-newspapers-do" data-ga="blog_toc_clicked" data-ga-heading-id="why-not-place-the-message-in-advance-like-newspapers-do">Why not place the message in advance like newspapers do</a></li><li><a href="#generator-performance-and-why-it-does-not-run-on-the-phone" data-ga="blog_toc_clicked" data-ga-heading-id="generator-performance-and-why-it-does-not-run-on-the-phone">Generator performance and why it does not run on the phone</a></li><li><a href="#generating-at-scale" data-ga="blog_toc_clicked" data-ga-heading-id="generating-at-scale">Generating at scale</a></li><li><a href="#what-the-approach-does-not-cover" data-ga="blog_toc_clicked" data-ga-heading-id="what-the-approach-does-not-cover">What the approach does not cover</a></li></ol></nav><p>You can generate a Swedish-style crossword (arrowword) with a hidden message in two phases. First use a local search to find the mask, that is, the layout of clue cells and letter cells, and then fill it with dictionary words as a constraint satisfaction problem (CSP) solved with backtracking. Do not place the hidden message in advance. Only once the crossword is finished, look for a joke whose punchline can be read in it from left to right and top to bottom.</p>
<p>This is how the arrowwords in our crosswords and word search app (<a href="https://smallwater.eu/crosswords-ios">App Store</a>, <a href="https://smallwater.eu/crosswords-android">Google Play</a>) are made. One grid takes from a few seconds to tens of seconds of CPU time, so we generate them in advance on a more powerful computer and bundle them with the app. Word searches are generated in a completely different and simpler way, described in <a href="https://smallwater.eu/en/blog/word-search-generator/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/swedish-crossword-generator/" data-ga-destination="/en/blog/word-search-generator/" data-ga-placement="inline">How to generate a word search with a hidden message</a>.</p>
<h2 id="what-an-arrowword-has-to-satisfy"><a class="heading-anchor" href="#what-an-arrowword-has-to-satisfy" aria-hidden="true" tabindex="-1">#</a>What an arrowword has to satisfy</h2>
<p>An arrowword has no black squares. Every cell is either a letter or a clue with an arrow pointing right or down. That gives a set of hard constraints:</p>
<ul>
<li>Every letter belongs to both an across and a down word. A letter crossed by only one word cannot be checked and looks like a mistake.</li>
<li>Every word has at least two letters and a clue in front of it. That is why the first row and the first column are always all clues.</li>
<li>The letters form one connected area, with no detached islands.</li>
<li>Every word must be in the dictionary and have at least one clue. No word appears twice in a grid.</li>
</ul>
<p>On top of that come soft quality criteria: few two-letter words, no clues that point at nothing, and as few very long words as possible, because they are hard to fill. The problem is computationally hard. <a href="https://link.springer.com/chapter/10.1007/978-3-642-30347-0_15" target="_blank" rel="noopener">Engel et al. (2012)</a> showed that filling a mask with words is NP-complete on its own.</p>
<h2 id="why-filling-the-grid-word-by-word-is-not-enough"><a class="heading-anchor" href="#why-filling-the-grid-word-by-word-is-not-enough" aria-hidden="true" tabindex="-1">#</a>Why filling the grid word by word is not enough</h2>
<p>The first idea is to do what a person with a pencil would do: split the hidden message into words, lay them out in a row like in a newspaper, run a crossing word through each of its letters and fill the rest of the grid with a greedy algorithm. A greedy algorithm picks whatever looks best at each step and never revisits the decision. Where nothing fits, a hole remains. And because greedy filling cannot go back, once the first ten words are bad, the rest of the grid cannot fix them.</p>
<p>A better word selection heuristic helps only a little. You can try crossing word lengths from medium, around five letters, towards the extremes. Or you can go evolutionary: a population of partial crosswords, five children per parent with one more word each, a score based on filled cells, vowel count and how evenly the letters are spread, and only the best move on to the next generation. The results get somewhat denser, but the holes stay, because the heuristic only sees the next step.</p>
<p>The way out comes from Jakob Engel&#39;s bachelor thesis at TU Munich (2009); he is also the first author of the paper above. Split the task into two independent phases: first find a good mask without caring about words, then fill it. Engel used an evolutionary algorithm for the mask. We use a simpler local search with random shakes, described in the next step.</p>
<h2 id="step-1-the-mask-with-hill-climbing"><a class="heading-anchor" href="#step-1-the-mask-with-hill-climbing" aria-hidden="true" tabindex="-1">#</a>Step 1: the mask with hill climbing</h2>
<p>The mask is a grid in which every cell is either a clue or a letter. Hill climbing is enough to find one: a local search that makes small changes and keeps those that do not make the result worse.</p>
<p>Start at random, with clues on about 22% of the inner cells. Give every mask a penalty score. Hard violations (a word that is too short, a letter without a crossing word, a detached island) should mostly cost 1,000 points; soft flaws (two-letter words over budget, a clue that points at no word, clusters of clues) 50 to 500.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/mask-random.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/mask-random.png" alt="A random 10×10 mask with many problem cells highlighted in red and orange" width="968" height="978" loading="lazy" decoding="async" /></picture><figcaption>A random mask. Red cells are letters with a violation, orange ones are clues pointing nowhere. Penalty 35,500.</figcaption></figure>
<p>Then, in a loop, flip one cell from clue to letter or back, or move a clue one cell diagonally. Keep the change if the penalty did not go up, otherwise revert it. Aim half of the changes at the cells with the highest penalty, and the gross errors disappear quickly:</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/mask-mid.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/mask-mid.png" alt="The same mask after 150 steps, only a few problem cells remain" width="968" height="978" loading="lazy" decoding="async" /></picture><figcaption>After 150 steps. Penalty 1,770.</figcaption></figure>
<p>Hill climbing gets stuck in local minima easily. When 2,000 steps bring no improvement, &quot;shake&quot; the grid: flip five random cells of the best mask found so far and keep searching. Those are the spikes in the chart. If even three shakes bring no better mask and it has no hard violations, stop.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/penalty.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/penalty.png" alt="A log-scale penalty chart over 8,300 steps: a fast drop from 35,500 to 200 and three short spikes after shakes" width="1800" height="850" loading="lazy" decoding="async" /></picture><figcaption>Penalty while generating a 10×10 mask. The dots mark the two previous snapshots.</figcaption></figure>
<p>The final mask has a residual penalty of 200 for soft flaws, but no hard violation:</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/mask-final.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/mask-final.png" alt="A finished 10×10 mask with arrows in the clue cells and empty letter cells" width="968" height="978" loading="lazy" decoding="async" /></picture><figcaption>The finished mask. Arrows show the direction of each word.</figcaption></figure>
<p>It pays to have the penalties for a clue without a word and for a detached island in the mask from the start. On a large set of finished crosswords without them, 13.2% of clues pointed at no word and 23.5% of grids had a detached island of letters. With them, the figures are 5.0% and 0%.</p>
<h2 id="step-2-filling-with-words-as-a-csp"><a class="heading-anchor" href="#step-2-filling-with-words-as-a-csp" aria-hidden="true" tabindex="-1">#</a>Step 2: filling with words as a CSP</h2>
<p>The mask yields a list of slots, places for words with a known length and known crossings. Filling them is a classic constraint satisfaction problem (CSP): every slot is a variable, its domain is the set of words that fit, and the constraints are matching letters at the crossings. It is solved with backtracking, a search that steps back and tries another option whenever it hits a dead branch:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">for every slot: domain = all words of that length
while not all slots are filled:
    pick the slot with the smallest domain (on a tie, the one with most crossings)
    for every word in its domain, best first:
        place the word
        narrow the domains of crossing slots to words with the matching letter
        if any domain became empty: undo, try the next word
        otherwise continue with the next slot
    if no word fits: go back one slot
give up after 50,000 steps</code></pre>
<p>In the literature these heuristics are called MRV (minimum remaining values, the slot with the fewest options first) and forward checking (narrow the neighbours&#39; domains after every placement, so a dead branch shows up immediately, not ten words later). Pre-index the dictionary by length, position and letter. &quot;Words with Q in third place&quot; is then a single set intersection, not a scan of the whole dictionary.</p>
<p>Word order matters too. Put words with more distinct clues in the dictionary first. It is a simple signal of a common crossword word. Without it, the grid fills up with obscure abbreviations.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/filled.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/filled.png" alt="A 10×10 mask filled with English words" width="968" height="978" loading="lazy" decoding="async" /></picture><figcaption>The filled grid. The images use the English dictionary; the numbers in the tables come from the Slovak one.</figcaption></figure>
<h2 id="step-3-the-hidden-message-in-the-finished-grid"><a class="heading-anchor" href="#step-3-the-hidden-message-in-the-finished-grid" aria-hidden="true" tabindex="-1">#</a>Step 3: the hidden message in the finished grid</h2>
<p>While filling, you do not know the hidden message yet. Read the letters of the finished grid from left to right and top to bottom and look for a joke whose punchline is a subsequence of that string. The letters come in the right order, but they do not have to be adjacent. If one is found, spread its letters as evenly as possible across the grid, so the punchline cannot be guessed from a single word.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/secret-order.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/secret-order.png" alt="The filled grid with ten coral letters S, P, A, C, E, S, U, I, T, S numbered in reading order" width="968" height="978" loading="lazy" decoding="async" /></picture><figcaption>The hidden message SPACESUITS in reading order. 111 different punchlines from our English joke list would fit this grid.</figcaption></figure>
<p>This order (grid first, joke second) increases the number of finished crosswords dramatically. In our measurement a single 12×12 grid fits on average 86 jokes from the Slovak list (5,960 jokes). So there is always one that has not been used in that size yet.</p>
<h2 id="why-not-place-the-message-in-advance-like-newspapers-do"><a class="heading-anchor" href="#why-not-place-the-message-in-advance-like-newspapers-do" aria-hidden="true" tabindex="-1">#</a>Why not place the message in advance like newspapers do</h2>
<p>In newspapers the hidden message is usually in one place, in a row, split into words. There are two ways to get that, and both give very few results.</p>
<p>The first is to place the message in the grid in advance. Then it has to fit. Our punchlines have 10.3 letters and 2 words on average. This is the share of jokes from the Slovak list that can be placed in a given size at all:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Grid</th>
<th align="right">Whole punchline in one row</th>
<th align="right">Punchline split into words (at most 3)</th>
</tr>
</thead>
<tbody><tr>
<td>8×8</td>
<td align="right">0%</td>
<td align="right">22%</td>
</tr>
<tr>
<td>10×10</td>
<td align="right">0.1%</td>
<td align="right">64%</td>
</tr>
<tr>
<td>12×12</td>
<td align="right">45%</td>
<td align="right">89%</td>
</tr>
<tr>
<td>15×15</td>
<td align="right">84%</td>
<td align="right">98%</td>
</tr>
</tbody></table>
</div>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/newspaper.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/newspaper.png" alt="A 10×10 grid in which the hidden message words KNOW and RULES are placed in advance as whole across words, in coral" width="968" height="978" loading="lazy" decoding="async" /></picture><figcaption>The hidden message placed in advance as whole words in rows, the newspaper way. Generated by the same generator.</figcaption></figure>
<p>With the message placed in advance, filling small grids succeeds about as often as without it. For 15×15 it was 5 out of 60 attempts versus 8 without it, for 18×18 none out of 60. Each such grid also serves a single joke, and in small sizes most jokes cannot even be placed.</p>
<p>The second way is to let the grid form freely and look for the punchline in it as a contiguous run. In a sample of 2,200 finished Slovak crosswords (200 of each size from 8×8 to 18×18), the whole punchline appeared as a contiguous run in one row or column in only 81 grids (3.7%). When each word of the punchline may sit somewhere else, it is 443 grids (20%), still without requiring the words to be next to each other. Reading in order found a joke in every one of them. At the price of a hidden message scattered across the grid you get orders of magnitude more crosswords.</p>
<aside class="app-box app-box-callout"><img class="app-box-img" src="https://smallwater.eu/images/crosswords/1-crosswords.jpg" alt="" width="698" height="1516" loading="lazy" decoding="async" /><div class="app-box-body"><p class="app-box-eyebrow">Try it</p><h2 class="app-box-title">Crosswords &amp; Puzzles</h2><p>Crosswords, word search, sudoku and nonograms in one offline app. How the puzzles are generated and what&#39;s new.</p><div class="app-box-links"><a class="btn btn-primary" href="https://smallwater.eu/crosswords-ios" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="crosswords" data-ga-destination="/crosswords-ios" data-ga-app="crosswords" data-ga-store="app_store">App Store</a><a class="btn btn-ghost" href="https://smallwater.eu/crosswords-android" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="crosswords" data-ga-destination="/crosswords-android" data-ga-app="crosswords" data-ga-store="google_play">Google Play</a></div></div></aside>
<h2 id="generator-performance-and-why-it-does-not-run-on-the-phone"><a class="heading-anchor" href="#generator-performance-and-why-it-does-not-run-on-the-phone" aria-hidden="true" tabindex="-1">#</a>Generator performance and why it does not run on the phone</h2>
<p>The generator ran 60 attempts per size with the Slovak dictionary, single-threaded (JavaScript on Node 22, an Intel Xeon 2.8 GHz server CPU in the cloud). An attempt is one mask and one fill. &quot;Successful fill&quot; means the CSP found a solution within 50,000 steps.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Grid</th>
<th align="right">Mask</th>
<th align="right">Fill</th>
<th align="right">Successful fill</th>
<th align="right">Words per grid</th>
<th align="right">Jokes per grid</th>
</tr>
</thead>
<tbody><tr>
<td>8×8</td>
<td align="right">6 ms</td>
<td align="right">0.50 s</td>
<td align="right">5%</td>
<td align="right">21</td>
<td align="right">0 (of 3 grids)</td>
</tr>
<tr>
<td>10×10</td>
<td align="right">0.16 s</td>
<td align="right">0.86 s</td>
<td align="right">20%</td>
<td align="right">33</td>
<td align="right">26</td>
</tr>
<tr>
<td>12×12</td>
<td align="right">0.25 s</td>
<td align="right">1.45 s</td>
<td align="right">43%</td>
<td align="right">52</td>
<td align="right">86</td>
</tr>
<tr>
<td>15×15</td>
<td align="right">0.32 s</td>
<td align="right">1.82 s</td>
<td align="right">13%</td>
<td align="right">80</td>
<td align="right">424</td>
</tr>
<tr>
<td>18×18</td>
<td align="right">0.52 s</td>
<td align="right">1.82 s</td>
<td align="right">13%</td>
<td align="right">118</td>
<td align="right">725</td>
</tr>
</tbody></table>
</div>
<p>What the numbers show:</p>
<ul>
<li><strong>A small grid is not an easy one.</strong> An 8×8 grid has many short slots with many crossings and little freedom; filling succeeds in only 5% of attempts. One filled grid costs on average about 10 seconds of CPU time at 8×8, 4 seconds at 12×12 and 18 seconds at 18×18.</li>
<li><strong>Most attempts fail.</strong> So run attempts in parallel on all cores and take the first result that is good enough.</li>
<li><strong>It makes no sense on the phone.</strong> Players do not want to wait tens of seconds when they open a puzzle, and the app would have to ship the whole dictionary with clues. A generator inside the app also has to keep yielding to the UI, otherwise the app freezes.</li>
</ul>
<h2 id="generating-at-scale"><a class="heading-anchor" href="#generating-at-scale" aria-hidden="true" tabindex="-1">#</a>Generating at scale</h2>
<p>The generator therefore belongs on a computer with a multi-core CPU. In our setup each core gets its own thread with the dictionary loaded once at startup. A grid is done when one of the threads returns a result with few enough unused cells. In continuous mode it keeps generating one crossword of every size in every language. That is how we got over 14,000 crosswords in four languages (Slovak, Czech, English, German) and eleven sizes from 8×8 to 18×18. Each joke is used at most once per size and language.</p>
<p>At this volume two things matter:</p>
<ul>
<li><strong>Format.</strong> A full record takes about 28 kB per crossword and 390 MB for the whole corpus. Most of it can be derived (coordinates, cell types, clue numbers). A compact format with the grid stored as a single string takes about 2 kB, 15 MB for the corpus and about 7 MB in the app&#39;s install package.</li>
<li><strong>Measuring quality.</strong> Measure the share of clues without a word and of grids with islands after every generation, and let the check fail if the numbers get worse.</li>
</ul>
<h2 id="what-the-approach-does-not-cover"><a class="heading-anchor" href="#what-the-approach-does-not-cover" aria-hidden="true" tabindex="-1">#</a>What the approach does not cover</h2>
<ul>
<li><strong>Clues without words.</strong> The average crossword still has 2.7 clues that point at nothing. The top left corner is always one of them.</li>
<li><strong>Dictionaries without frequencies.</strong> The German dictionary from Wiktionary has a single clue for most words, so ranking by clue count barely discriminates and rare words end up in the grid. A frequency list would help; we do not have one yet.</li>
<li><strong>The hidden message is not in one place.</strong> People used to newspapers may look for it in a row. It is a compromise, not the ideal.</li>
</ul>
<p>If you are working on a similar content generation problem or need a mobile app, <a href="https://smallwater.eu/en/#kontakt">get in touch</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to generate a 3D phone model in the browser</title>
      <link>https://smallwater.eu/en/blog/threejs-csg-3d-phone-model/</link>
      <guid isPermaLink="true">https://smallwater.eu/en/blog/threejs-csg-3d-phone-model/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Ecranify</category>
      <description>A guide to a 3D phone model in the browser with Three.js and CSG: a beveled body, button and speaker cutouts, a Web Worker and fast subtraction of all holes.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/cover.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/cover.jpg" alt="Two generated 3D phone models, one from the front with a dark display and one from the back" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Contents"><p class="post-toc-title">Contents</p><ol><li><a href="#what-you-will-need" data-ga="blog_toc_clicked" data-ga-heading-id="what-you-will-need">What you will need</a></li><li><a href="#step-1-extrude-a-rectangle-into-3d" data-ga="blog_toc_clicked" data-ga-heading-id="step-1-extrude-a-rectangle-into-3d">Step 1: Extrude a rectangle into 3D</a></li><li><a href="#step-2-carve-a-recess-for-the-display" data-ga="blog_toc_clicked" data-ga-heading-id="step-2-carve-a-recess-for-the-display">Step 2: Carve a recess for the display</a></li><li><a href="#step-3-prepare-side-features-as-drill-bits" data-ga="blog_toc_clicked" data-ga-heading-id="step-3-prepare-side-features-as-drill-bits">Step 3: Prepare side features as drill bits</a></li><li><a href="#step-4-do-not-drill-front-details" data-ga="blog_toc_clicked" data-ga-heading-id="step-4-do-not-drill-front-details">Step 4: Do not drill front details</a></li><li><a href="#step-5-move-the-work-into-a-web-worker" data-ga="blog_toc_clicked" data-ga-heading-id="step-5-move-the-work-into-a-web-worker">Step 5: Move the work into a Web Worker</a></li><li><a href="#step-6-subtract-all-holes-at-once" data-ga="blog_toc_clicked" data-ga-heading-id="step-6-subtract-all-holes-at-once">Step 6: Subtract all holes at once</a></li><li><a href="#step-7-keep-the-segment-count-as-a-parameter" data-ga="blog_toc_clicked" data-ga-heading-id="step-7-keep-the-segment-count-as-a-parameter">Step 7: Keep the segment count as a parameter</a></li><li><a href="#rendering-on-a-server-without-a-gpu" data-ga="blog_toc_clicked" data-ga-heading-id="rendering-on-a-server-without-a-gpu">Rendering on a server without a GPU</a></li></ol></nav><p>To generate a 3D phone model in the browser, extrude a rounded rectangle to the device depth and then use boolean operations (CSG) to cut out the front panel, buttons, speakers and port. The whole device is then described by a handful of numbers in millimeters, and a new model means changing those numbers, not modeling in a 3D package.</p>
<p>This is useful wherever you need many similar devices or want users to adjust them: configurators, mockups, product previews. A real-world example is <a href="https://ecranify.com" target="_blank" rel="noopener">Ecranify</a>, a tool for 3D mockups for App Store and Google Play screenshots that does not contain a single pre-made 3D model. Below is the process step by step, including a measurement that shows where the computation can be cut the most.</p>
<h2 id="what-you-will-need"><a class="heading-anchor" href="#what-you-will-need" aria-hidden="true" tabindex="-1">#</a>What you will need</h2>
<ul>
<li><a href="https://threejs.org" target="_blank" rel="noopener">Three.js</a> for the scene, cameras, lights and above all geometry. In Three.js, geometry is a mesh, a set of triangles.</li>
<li>The <a href="https://github.com/gkjohnson/three-bvh-csg" target="_blank" rel="noopener">three-bvh-csg</a> library for boolean operations on meshes. It speeds them up with a spatial index (BVH, bounding volume hierarchy), so it does not test every triangle against every other one.</li>
<li>A description of the device as data, for example JSON with body dimensions, corner radius, bevel size and a list of features on each side. Give all positions in millimeters from the device center.</li>
</ul>
<h2 id="step-1-extrude-a-rectangle-into-3d"><a class="heading-anchor" href="#step-1-extrude-a-rectangle-into-3d" aria-hidden="true" tabindex="-1">#</a>Step 1: Extrude a rectangle into 3D</h2>
<p>Draw a 2D rounded rectangle at actual size, for example 163.4 × 78 mm, and extrude it to a depth of 8.75 mm. You get a slab with sharp edges. No phone looks like that, so turn on a bevel, a rounded edge, during the extrusion as well. Three.js does both in one step.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Extrusion only</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/slab-sharp.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/slab-sharp.jpg" alt="A boxy slab with no rounded corners or edges" width="1200" height="800" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">+ corners and bevel</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/slab-bevel.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/slab-bevel.jpg" alt="The same slab with rounded corners and edges" width="1200" height="800" loading="lazy" decoding="async" /></picture></div></div><figcaption>Same shape, two different numbers: corner radius and bevel size.</figcaption></figure>
<p>One thing to watch: the bevel is added on the outside. If you need exact outer dimensions, shrink the 2D shape and the depth by the bevel size.</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">b = min(bevel, depth / 2)
shape = rounded rectangle(width - 2b, height - 2b, corner radius - b)
body = extrude(shape, depth - 2b, bevel b)</code></pre>
<p>How smooth the curves are (tessellation) depends on the number of segments each curve is split into. More segments means a smoother shape and more triangles. This number later decides how fast the whole computation is, so we come back to it in step 7.</p>
<h2 id="step-2-carve-a-recess-for-the-display"><a class="heading-anchor" href="#step-2-carve-a-recess-for-the-display" aria-hidden="true" tabindex="-1">#</a>Step 2: Carve a recess for the display</h2>
<p>CSG (constructive solid geometry) builds solids from simpler ones with three operations: union, subtraction and intersection. Subtraction works like a drill bit: one solid removes material from another.</p>
<p>A phone display is not flush with the frame. It sits in a shallow recess. To make it, create a second copy of the body that is 1.4 mm smaller, keep only its front part, 0.7 mm thick (an intersection with a large box), and subtract that part from the body. Then place a dark display surface into the recess.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Cutter</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/pocket-cutter.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/pocket-cutter.jpg" alt="Phone body with a blue translucent cutter on the front" width="1200" height="800" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">After subtraction</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/pocket-carved.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/pocket-carved.jpg" alt="Body with the carved recess and a dark display inside" width="1200" height="800" loading="lazy" decoding="async" /></picture></div></div><figcaption>The blue solid is what gets subtracted. On the right the display already sits in the recess.</figcaption></figure>
<p>Two practical details:</p>
<ul>
<li><strong>The intersection box has to be really large.</strong> It only needs to clip depth, but in width and height it must exceed the whole device. Do not derive it from the depth: 20 times the depth, for example, is less than the height of a thin, tall phone, and an uncut frame is left at the top and bottom.</li>
<li><strong>Scale the cutter up by a few tenths of a percent.</strong> When two solids share exactly the same face, CSG cannot tell which side is inside. A scale of 1.002 prevents that and is invisible to the eye.</li>
</ul>
<h2 id="step-3-prepare-side-features-as-drill-bits"><a class="heading-anchor" href="#step-3-prepare-side-features-as-drill-bits" aria-hidden="true" tabindex="-1">#</a>Step 3: Prepare side features as drill bits</h2>
<p>Every feature on the side follows the same recipe with a different drill bit. A button, for example, is made from three solids: a rounded rectangle slightly deeper than the button is subtracted from the frame, the edge of the opening gets a small chamfer, and a slightly smaller solid that protrudes a little from the frame goes into the opening.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Drill bits</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/button-cutters.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/button-cutters.jpg" alt="Side of a phone with two blue button cutters" width="1200" height="800" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Cut and button</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/button-cut.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/button-cut.jpg" alt="Side of a phone with cut openings and inserted buttons" width="1200" height="800" loading="lazy" decoding="async" /></picture></div></div><figcaption>Two buttons on the right side before and after subtraction.</figcaption></figure>
<p>Speakers are a row of small cylinders, the port is a rounded rectangle. Put something dark into every opening: a thin disc into the grille, a plate into the port. An empty hole in a thin shell looks like a bug, a hole with a dark insert looks like hardware.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/bottom-edge.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/bottom-edge.jpg" alt="Bottom edge of a phone with two rows of speaker holes and a port in the middle" width="1200" height="800" loading="lazy" decoding="async" /></picture><figcaption>Speaker holes and a port. All of them are subtracted cylinders and rounded rectangles.</figcaption></figure>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">for each feature in the side feature list:
    bit = feature shape (cylinder, rounded rectangle, ...)
    place the bit on the edge at its position in mm
    add the bit to the list of cuts
    if the feature has an insert: add the insert to the scene</code></pre>
<p>Adding a button is then a data entry. Features add up quickly: an iPad Pro 13″ has 58 speaker holes, 4 buttons, 2 antennas and a port, more than 60 subtractions from a single body.</p>
<h2 id="step-4-do-not-drill-front-details"><a class="heading-anchor" href="#step-4-do-not-drill-front-details" aria-hidden="true" tabindex="-1">#</a>Step 4: Do not drill front details</h2>
<p>The camera cutout, the island at the top of the display and the home indicator are flat. Cutting geometry for them is wasted work. Apply them as decals instead, images projected onto a surface. Project them onto a hidden plane covering the whole front and position them in the same millimeters as everything else. The exception is the round home button of older iPhones. It has real depth you can see from the side, so it goes through CSG.</p>
<aside class="app-box app-box-callout"><img class="app-box-img" src="https://smallwater.eu/images/ecranify/home.png" alt="" width="1400" height="1219" loading="lazy" decoding="async" /><div class="app-box-body"><p class="app-box-eyebrow">Try it</p><h2 class="app-box-title">Ecranify</h2><p>A browser tool for photorealistic 3D device mockups for App Store and Google Play screenshots. WebGL notes and updates.</p><div class="app-box-links"><a class="btn btn-primary" href="https://ecranify.com" target="_blank" rel="noopener" data-ga="blog_app_link_clicked" data-ga-category="ecranify" data-ga-destination="https://ecranify.com" data-ga-app="ecranify" data-ga-store="web">ecranify.com</a></div></div></aside>
<h2 id="step-5-move-the-work-into-a-web-worker"><a class="heading-anchor" href="#step-5-move-the-work-into-a-web-worker" aria-hidden="true" tabindex="-1">#</a>Step 5: Move the work into a Web Worker</h2>
<p>JavaScript in the browser runs on a single thread. The same thread computes, paints the page and responds to clicks. While CSG runs, the page freezes, and a spinner will not help, because it needs that same thread too.</p>
<p>So run the whole device build in a <a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API" target="_blank" rel="noopener">Web Worker</a>, a separate background thread. The worker receives the device description, returns finished vertex arrays, and the page stays smooth. When a parameter changes, terminate the old worker and start a new one, so it does not keep computing an outdated state.</p>
<p>The worker result takes a moment, so add an instant preview. Show the feature being edited as a blue translucent &quot;ghost&quot;, a simple box or cylinder with a few dozen triangles that can be built on the main thread right away. When the worker finishes, the ghost disappears.</p>
<h2 id="step-6-subtract-all-holes-at-once"><a class="heading-anchor" href="#step-6-subtract-all-holes-at-once" aria-hidden="true" tabindex="-1">#</a>Step 6: Subtract all holes at once</h2>
<p>The most direct approach is to subtract features one after another: hole 1 from the body, then hole 2 from the result, and so on. But every subtraction rebuilds the spatial index of the whole body, which is the most expensive part of the computation. With dozens of features the time grows very fast.</p>
<p>It is better to collect all drill bits for a given solid first, merge them into one mesh and subtract once.</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">cuts = []
for each feature: add its bit to cuts
merged = merge all cuts into one mesh
body = body minus merged
if merging fails: subtract cuts one by one</code></pre>
<p>Measured on a simplified copy of the pipeline (Three.js 0.170, three-bvh-csg 0.0.17, Node 22, a cloud server with 2 Intel Xeon 2.1 GHz cores, median of 5 runs):</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Solid</th>
<th>Holes</th>
<th>One at a time</th>
<th>All at once</th>
</tr>
</thead>
<tbody><tr>
<td>phone, 17,676 triangles</td>
<td>25</td>
<td>2.47 s</td>
<td>0.26 s</td>
</tr>
<tr>
<td>tablet, 17,676 triangles</td>
<td>58</td>
<td>5.55 s</td>
<td>0.89 s</td>
</tr>
<tr>
<td>tablet, 68,108 triangles</td>
<td>58</td>
<td>22.8 s</td>
<td>3.1 s</td>
</tr>
</tbody></table>
</div>
<p>One condition: merging and subtracting once gives the same result as subtracting one by one only if the drill bits do not overlap. Features sit in different places, so it usually holds. But if a user drags two features on top of each other, a small artifact can appear where they overlap.</p>
<h2 id="step-7-keep-the-segment-count-as-a-parameter"><a class="heading-anchor" href="#step-7-keep-the-segment-count-as-a-parameter" aria-hidden="true" tabindex="-1">#</a>Step 7: Keep the segment count as a parameter</h2>
<p>Tessellation that is fine for display is often needlessly dense for CSG. With 64 segments per corner and 32 for the bevel, the body has 68,108 triangles. Half of that (32 and 16) is invisible on a 2 mm edge, but the body has only 17,676 triangles, and the third row of the table shows what that does to the time.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">64 and 32 segments</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/tessellation-64.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/tessellation-64.png" alt="Wireframe of a phone corner with a very dense triangle mesh" width="1200" height="800" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">32 and 16 segments</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/tessellation-32.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/tessellation-32.png" alt="Wireframe of the same corner with half the segments" width="1200" height="800" loading="lazy" decoding="async" /></picture></div></div><figcaption>The body corner from above. The right one has a quarter of the triangles.</figcaption></figure>
<p>Give small features with a radius of up to 2 mm (speaker holes, screws) half the segments, gallery thumbnails even fewer. So do not hard-code quality as a constant. Make it a build parameter.</p>
<h2 id="rendering-on-a-server-without-a-gpu"><a class="heading-anchor" href="#rendering-on-a-server-without-a-gpu" aria-hidden="true" tabindex="-1">#</a>Rendering on a server without a GPU</h2>
<p>If you need to render the same scene on a server too, for example as an export backup, it runs in headless Chrome, which draws in software because there is no graphics card. Every inefficiency is multiplied there. In Ecranify, the heaviest tablet hit the 15-minute limit without the changes above. With merged cuts, smaller textures, lower tessellation and a cache of finished geometry keyed by a hash of the device description, a render takes about half a minute and a repeat one about 5 seconds. The details, with interactive demos, are in the <a href="https://ecranify.com/guides/how-we-build-3d-device-mockups" target="_blank" rel="noopener">article on ecranify.com</a>.</p>
<p>If you are solving a similar 3D problem in the browser or need a custom app, <a href="https://smallwater.eu/en/#kontakt">get in touch</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to upgrade a Unity 4 project to Unity 6</title>
      <link>https://smallwater.eu/en/blog/unity-4-to-unity-6-upgrade/</link>
      <guid isPermaLink="true">https://smallwater.eu/en/blog/unity-4-to-unity-6-upgrade/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Onager</category>
      <description>How to move a Unity 4 project to Unity 6 without losing scenes: text conversion in Unity 2019.4, a UnityScript to C# port that keeps GUIDs and a recovered HUD.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/cover.webp" /><img src="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/cover.jpg" alt="A block structure in an Onager level on a frosted glass floor" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Contents"><p class="post-toc-title">Contents</p><ol><li><a href="#what-happens-when-you-open-the-project-in-unity-6" data-ga="blog_toc_clicked" data-ga-heading-id="what-happens-when-you-open-the-project-in-unity-6">What happens when you open the project in Unity 6</a></li><li><a href="#step-1-record-the-original-state" data-ga="blog_toc_clicked" data-ga-heading-id="step-1-record-the-original-state">Step 1: Record the original state</a></li><li><a href="#step-2-convert-to-text-through-unity-2019-4" data-ga="blog_toc_clicked" data-ga-heading-id="step-2-convert-to-text-through-unity-2019-4">Step 2: Convert to text through Unity 2019.4</a></li><li><a href="#step-3-verify-the-result-in-the-editor-not-by-reading-yaml" data-ga="blog_toc_clicked" data-ga-heading-id="step-3-verify-the-result-in-the-editor-not-by-reading-yaml">Step 3: Verify the result in the editor, not by reading YAML</a></li><li><a href="#step-4-unityscript-to-c-with-the-same-guid" data-ga="blog_toc_clicked" data-ga-heading-id="step-4-unityscript-to-c-with-the-same-guid">Step 4: UnityScript to C# with the same GUID</a></li><li><a href="#step-5-rebuild-the-hud-from-the-original-binary-scenes" data-ga="blog_toc_clicked" data-ga-heading-id="step-5-rebuild-the-hud-from-the-original-binary-scenes">Step 5: Rebuild the HUD from the original binary scenes</a></li><li><a href="#after-the-conversion-replace-old-workarounds" data-ga="blog_toc_clicked" data-ga-heading-id="after-the-conversion-replace-old-workarounds">After the conversion: replace old workarounds</a></li><li><a href="#what-this-process-does-not-solve" data-ga="blog_toc_clicked" data-ga-heading-id="what-this-process-does-not-solve">What this process does not solve</a></li></ol></nav><p>Don&#39;t open a Unity 4 project directly in Unity 6, and above all don&#39;t save anything in it. Unity 6 only partly loads the old binary scenes and saving makes the loss permanent; the safe route goes through Unity 2019.4, which can both read the old format and write it back as text, and only then on to Unity 6.</p>
<p>This applies to projects with scenes and prefabs stored in the binary format and scripts written in UnityScript, the JavaScript-like language Unity used to support alongside C#. The example throughout is Onager, a catapult physics game written in Unity 4.1.2 and UnityScript. The project had 126 scripts, 75 scenes and 78 prefabs. After the conversion it runs on Unity 6000.5 with URP (Universal Render Pipeline) and is free on the <a href="https://smallwater.eu/onager-ios">App Store</a> and <a href="https://smallwater.eu/onager-android">Google Play</a>.</p>
<h2 id="what-happens-when-you-open-the-project-in-unity-6"><a class="heading-anchor" href="#what-happens-when-you-open-the-project-in-unity-6" aria-hidden="true" tabindex="-1">#</a>What happens when you open the project in Unity 6</h2>
<p>The simplest approach is to open the project in Unity 6 and switch serialization to Force Text, which stores assets as readable YAML. That is not a straight file conversion. Unity loads every asset through its importer and writes it back as YAML, so everything passes through the Unity 6 loader. On every scene it reports a Transform that is not assigned to a GameObject and deletes the &quot;dangling&quot; component. Saving makes that permanent.</p>
<p>You can measure the loss with binary2text, a tool that ships with the editor and reads a serialized file without the importer. In Onager we compared its output with the resulting YAML and counted only components that disappeared from GameObjects that survived:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Component</th>
<th align="right">Lost</th>
</tr>
</thead>
<tbody><tr>
<td>Camera</td>
<td align="right">130</td>
</tr>
<tr>
<td>ParticleSystem</td>
<td align="right">132</td>
</tr>
<tr>
<td>Transform</td>
<td align="right">167</td>
</tr>
<tr>
<td>BoxCollider</td>
<td align="right">104</td>
</tr>
<tr>
<td>Rigidbody</td>
<td align="right">100</td>
</tr>
<tr>
<td>GUITexture / GUIText</td>
<td align="right">303 / 179</td>
</tr>
</tbody></table>
</div>
<p>Not one of the 75 scenes came through without loss. Whole HUD cameras vanished and the main camera was replaced with a new default one. The important part is that the damage happens on load, not on save. Until the project is converted, Unity 6 must not save it: not a single scene, not a single prefab.</p>
<h2 id="step-1-record-the-original-state"><a class="heading-anchor" href="#step-1-record-the-original-state" aria-hidden="true" tabindex="-1">#</a>Step 1: Record the original state</h2>
<p>Before you touch anything, take a screenshot of every scene and save a binary2text dump of every original scene file. You will check cameras against those dumps in step 3 and rebuild the HUD from them in step 5. Keep the original binary files in your git history; don&#39;t delete them.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/before-level-1-1.webp" /><img src="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/before-level-1-1.jpg" alt="The first level in Unity 6 before the remaster" width="1280" height="720" loading="lazy" decoding="async" /></picture><figcaption>The first Onager level in Unity 6, still with the old renderer and original materials. Capture every scene like this before you touch anything.</figcaption></figure>
<h2 id="step-2-convert-to-text-through-unity-2019-4"><a class="heading-anchor" href="#step-2-convert-to-text-through-unity-2019-4" aria-hidden="true" tabindex="-1">#</a>Step 2: Convert to text through Unity 2019.4</h2>
<p>Unity 2019.4 is the last editor that can both read and write the Unity 4.1 format. Do the conversion with the editor closed, in a separate copy of the repository:</p>
<ul>
<li><strong>Project version.</strong> Set the original version (4.1.2 for Onager) in the project version file so the editor takes its old-project path. Unity overwrites it on open; it only has to hold for the first import.</li>
<li><strong>Empty packages.</strong> Empty the package dependency list. A single compile error means no menu item from editor scripts works, which looks as if the menu never appeared at all.</li>
<li><strong>Import from scratch.</strong> Delete the Library folder and let the project import.</li>
<li><strong>Force Text and a forced rewrite.</strong> After switching to Force Text, Unity 2019.4 does not rewrite existing files by itself. You have to trigger it with the asset reserialization function, for the assets only, not their meta files. Rewriting every meta file makes Unity 6 reimport in a loop afterwards.</li>
</ul>
<p>There is one trap in git. If your git attributes mark scenes as text with LF line endings, git strips bytes that look like line endings out of binary scenes when you add them to the repository. In Onager that damaged eight scenes. Don&#39;t put a line ending attribute on Unity assets; Unity writes LF anyway.</p>
<h2 id="step-3-verify-the-result-in-the-editor-not-by-reading-yaml"><a class="heading-anchor" href="#step-3-verify-the-result-in-the-editor-not-by-reading-yaml" aria-hidden="true" tabindex="-1">#</a>Step 3: Verify the result in the editor, not by reading YAML</h2>
<p>Reading the converted files leads to confident wrong conclusions. Shortened prefab instance records look like a loss, and so does a camera that has only moved into a prefab. What counts is what Unity builds in memory. Open every converted scene and compare its cameras with the dump of the original from step 1.</p>
<p>In Onager all 75 scenes ended up with the same cameras in the same order. The only losses were things no current editor knows: 569 GUITexture, 179 GUIText and the legacy particle systems Unity removed in 2018.</p>
<h2 id="step-4-unityscript-to-c-with-the-same-guid"><a class="heading-anchor" href="#step-4-unityscript-to-c-with-the-same-guid" aria-hidden="true" tabindex="-1">#</a>Step 4: UnityScript to C# with the same GUID</h2>
<p>Unity <a href="https://blog.unity.com/community/unityscripts-long-ride-off-into-the-sunset" target="_blank" rel="noopener">phased UnityScript out starting with 2017.2</a>, so a manual port to C# is the only way. Scenes reference scripts through the GUID, a unique identifier Unity stores in every asset&#39;s meta file, and store field values by field name. Keep both during the port and every reference reconnects by itself:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">for every UnityScript script X:
  create a C# script with a class named exactly X
  copy the GUID from the old script&#x27;s meta file into the new one&#x27;s
  public fields: same name, same type, typos included
  rename nothing until the project runs</code></pre>
<p>In Onager, after porting all 126 scripts this way, 12,935 components in 73 scenes reconnected and every value set in the Inspector stayed.</p>
<p>A text search of the project will not tell you which scripts are used. A binary prefab stores the GUID as bytes, not as text. In Onager twenty-one scripts looked unused and every one of them was live. Watch out for leftovers from earlier port attempts too: one wooden block prefab pointed at two scripts from such an attempt, and that prefab alone accounted for 1,140 missing components. If you need to change a GUID in a binary file, swap it right in the bytes, sixteen for sixteen, so the file does not shift.</p>
<h2 id="step-5-rebuild-the-hud-from-the-original-binary-scenes"><a class="heading-anchor" href="#step-5-rebuild-the-hud-from-the-original-binary-scenes" aria-hidden="true" tabindex="-1">#</a>Step 5: Rebuild the HUD from the original binary scenes</h2>
<p>GUITexture and GUIText do not exist in Unity 6, and the whole HUD built on them goes with them. You don&#39;t have to draw it again. binary2text reads the original binary scenes from your git history whether or not the classes still exist. Pull every HUD image and text out of them with its texture, colour, position and alignment, and have a script put them back on the same objects in the scenes. In Onager that was 577 images and 180 texts.</p>
<p>This also saves strings that exist nowhere else. In Onager that was 111 strings: every tutorial caption and the whole credits board. Writing the HUD from scratch would have meant making them up. When you carry colours over, keep in mind something the documentation does not say: the old GUITexture doubled the colour when drawing. All 577 images had a colour of exactly 0.5, so grey meant white.</p>
<h2 id="after-the-conversion-replace-old-workarounds"><a class="heading-anchor" href="#after-the-conversion-replace-old-workarounds" aria-hidden="true" tabindex="-1">#</a>After the conversion: replace old workarounds</h2>
<p>Old projects often worked around the limits of the Unity of their day, and those workarounds are worth rebuilding once the project is converted:</p>
<ul>
<li><strong>Floor reflection.</strong> Onager had a shiny floor without a real reflection. Below the floor stood a second, mirrored world: a copy of every object whose position a script copied from the original every third frame, 50 to 70 clones in one level. One extra camera drawing the reflection at half resolution replaces all of that. Particles and debris then reflect too, which the cloned world could not do.</li>
<li><strong>Aiming.</strong> If the trajectory preview tries to predict where a force added in the first physics step will send the shot, the shot lands somewhere other than the marker. It is more reliable to compute the velocity once, draw the preview from exactly that and assign it to the shot directly.</li>
<li><strong>Performance.</strong> Measure on a weak phone. On a Motorola G52, Onager dropped from 20 to 6 fps just because of the wall shader, more than resolution, shadows and the reflection combined. That is why the walls use a simpler material on low and medium detail.</li>
</ul>
<figure class="post-figure post-figure-video"><video class="post-video" muted loop playsinline controls preload="none" poster="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/gravity-shot.jpg" width="720" height="720" data-lazy-video aria-label="The gravity shot in the converted Onager: a sphere lifts the blocks, with a swirl and a blue light around it."><source src="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/gravity-shot.mp4" type="video/mp4" /></video><figcaption>The gravity shot in the converted Onager: a sphere lifts the blocks, with a swirl and a blue light around it.</figcaption></figure>
<aside class="app-box app-box-callout"><img class="app-box-img" src="https://smallwater.eu/images/onager/1-aim.jpg" alt="" width="1600" height="736" loading="lazy" decoding="async" /><div class="app-box-body"><p class="app-box-eyebrow">Try it</p><h2 class="app-box-title">Onager</h2><p>A 3D catapult physics puzzle game: 60 levels of demolition. Physics, level design and release notes.</p><div class="app-box-links"><a class="btn btn-primary" href="https://smallwater.eu/onager-ios" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="onager" data-ga-destination="/onager-ios" data-ga-app="onager" data-ga-store="app_store">App Store</a><a class="btn btn-ghost" href="https://smallwater.eu/onager-android" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="onager" data-ga-destination="/onager-android" data-ga-app="onager" data-ga-store="google_play">Google Play</a></div></div></aside>
<figure class="post-figure post-figure-video"><video class="post-video" muted loop playsinline controls preload="none" poster="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/rainbow-shot.jpg" width="720" height="720" data-lazy-video aria-label="The rainbow shot: white dots come straight out of the surface of the block it hits."><source src="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/rainbow-shot.mp4" type="video/mp4" /></video><figcaption>The rainbow shot: white dots come straight out of the surface of the block it hits.</figcaption></figure>
<p>If you deal with physics for thousands of objects in current Unity, see also the <a href="https://smallwater.eu/en/blog/fake-ragdoll-unity-dots/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/unity-4-to-unity-6-upgrade/" data-ga-destination="/en/blog/fake-ragdoll-unity-dots/" data-ga-placement="inline">fake ragdoll for thousands of enemies in Unity DOTS</a>. How to add glow and 2D lights to an old 2D game in URP after the upgrade is covered in a <a href="https://smallwater.eu/en/blog/dreamica-remaster-unity-urp-glow/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/unity-4-to-unity-6-upgrade/" data-ga-destination="/en/blog/dreamica-remaster-unity-urp-glow/" data-ga-placement="inline">separate tutorial</a>.</p>
<h2 id="what-this-process-does-not-solve"><a class="heading-anchor" href="#what-this-process-does-not-solve" aria-hidden="true" tabindex="-1">#</a>What this process does not solve</h2>
<p>Components that current Unity does not know will not reconnect. GUITexture and GUIText have to be rebuilt as in step 5; legacy particle systems are lost. Class and field names stay exactly as they were in the original project, typos and whatever language someone once wrote them in included. Scenes bind to them by name, so renaming only makes sense through the editor, which rewrites the references itself.</p>
<p>If you have an old Unity project that needs to reach a current version, <a href="https://smallwater.eu/en/#kontakt">get in touch</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to navigate a horde of thousands in Unity DOTS</title>
      <link>https://smallwater.eu/en/blog/unity-dots-horde-navigation/</link>
      <guid isPermaLink="true">https://smallwater.eu/en/blog/unity-dots-horde-navigation/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Tower Defense</category>
      <description>A guide to moving thousands of enemies in Unity DOTS: flow fields, pushing off neighbours and walls, crowd density cost, chokepoint jams and performance.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/cover.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/cover.jpg" alt="A horde of enemies as light dots flowing around obstacles to the goal, with flow field arrows and a red crowd density map underneath" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Contents"><p class="post-toc-title">Contents</p><ol><li><a href="#step-1-a-flow-field-instead-of-a-path-per-unit" data-ga="blog_toc_clicked" data-ga-heading-id="step-1-a-flow-field-instead-of-a-path-per-unit">Step 1: A flow field instead of a path per unit</a></li><li><a href="#step-2-a-separate-field-for-each-enemy-size" data-ga="blog_toc_clicked" data-ga-heading-id="step-2-a-separate-field-for-each-enemy-size">Step 2: A separate field for each enemy size</a></li><li><a href="#step-3-pushing-off-neighbours" data-ga="blog_toc_clicked" data-ga-heading-id="step-3-pushing-off-neighbours">Step 3: Pushing off neighbours</a></li><li><a href="#step-4-pushing-off-walls" data-ga="blog_toc_clicked" data-ga-heading-id="step-4-pushing-off-walls">Step 4: Pushing off walls</a></li><li><a href="#step-5-density-cost-and-slowdown" data-ga="blog_toc_clicked" data-ga-heading-id="step-5-density-cost-and-slowdown">Step 5: Density cost and slowdown</a></li><li><a href="#step-6-jams-at-a-chokepoint-wait-don-t-push" data-ga="blog_toc_clicked" data-ga-heading-id="step-6-jams-at-a-chokepoint-wait-don-t-push">Step 6: Jams at a chokepoint: wait, don t push</a></li><li><a href="#step-7-overtaking-large-enemies" data-ga="blog_toc_clicked" data-ga-heading-id="step-7-overtaking-large-enemies">Step 7: Overtaking large enemies</a></li><li><a href="#step-8-slots-around-the-attack-target" data-ga="blog_toc_clicked" data-ga-heading-id="step-8-slots-around-the-attack-target">Step 8: Slots around the attack target</a></li><li><a href="#simulation-and-rendering-performance" data-ga="blog_toc_clicked" data-ga-heading-id="simulation-and-rendering-performance">Simulation and rendering performance</a></li><li><a href="#limits-of-the-approach" data-ga="blog_toc_clicked" data-ga-heading-id="limits-of-the-approach">Limits of the approach</a></li></ol></nav><p>Steer a horde of thousands of enemies in Unity DOTS with one flow field for the whole map and a few cheap local rules: pushing off neighbours and walls, path cost that grows with crowd density, and a &quot;waiting&quot; state in jams. In the Unity Editor on a MacBook with an M3 Pro, that handles around 9,600 enemies at once at 90 FPS.</p>
<p>The problem shows up in tower defense, strategy and survival games where thousands of units head for a goal at the same time. Classic per-unit pathfinding is too expensive, and without local rules the crowd overlaps, hugs walls and gets stuck in chokepoints. Unity DOTS (Data-Oriented Technology Stack: Entities, ECS and the Job System) gives you the performance for thousands of entities, but it doesn&#39;t solve navigation for you. We use the approach below in our PC tower defense game (it&#39;s in our <a href="https://smallwater.eu/en/#referencie">portfolio</a>). The images come from a simplified 2D simulation running the same rules. They are not game footage.</p>
<h2 id="step-1-a-flow-field-instead-of-a-path-per-unit"><a class="heading-anchor" href="#step-1-a-flow-field-instead-of-a-path-per-unit" aria-hidden="true" tabindex="-1">#</a>Step 1: A flow field instead of a path per unit</h2>
<p>A flow field is a grid over the whole map in which every cell knows which direction leads to the goal. An enemy doesn&#39;t search for a path, it just reads the direction at the spot where it stands. The cost is the same for 10 or 10,000 enemies.</p>
<p>Split the map into 1 m cells (our test level is 128 × 128) and, once per goal, compute the distance from every cell to the goal. A static field can be computed in the editor when the level is prepared and stored in an asset.</p>
<p>The simplest version is Dijkstra over 8 neighbours with one of 8 directions per cell. It works, but enemies walk diagonals and stick to walls in single file.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">8 directions</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/flow-8dir.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/flow-8dir.png" alt="Flow field with eight directions, enemy paths run diagonally and hug the edge of the obstacle" width="1792" height="1008" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Smooth field</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/flow-smooth.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/flow-smooth.png" alt="Smooth flow field, paths curve together around the obstacle corner and keep a distance from the wall" width="1792" height="1008" loading="lazy" decoding="async" /></picture></div></div><figcaption>Same map, paths of lone enemies. Left: 8 directions. Right: smooth field with a surcharge near walls.</figcaption></figure>
<p>A smooth field gives a better result. Compute the distance by solving the <a href="https://en.wikipedia.org/wiki/Eikonal_equation" target="_blank" rel="noopener">eikonal equation</a> (a wavefront equation whose solution is the shortest distance in continuous space, not just along grid edges) with the fast sweeping method, which passes over the grid repeatedly from all four corners. The direction is then not the direction of a cell but the distance gradient at the enemy&#39;s exact position. Add a surcharge to each cell&#39;s cost for being close to a wall, so the field itself keeps the crowd a little away from obstacles.</p>
<h2 id="step-2-a-separate-field-for-each-enemy-size"><a class="heading-anchor" href="#step-2-a-separate-field-for-each-enemy-size" aria-hidden="true" tabindex="-1">#</a>Step 2: A separate field for each enemy size</h2>
<p>A large enemy doesn&#39;t fit everywhere a small one does. Introduce size classes: each has its own walkability (a cell must be far enough from walls) and its own field per goal. Keep the classes as level data, not in code. We use three.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/size-classes.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/size-classes.png" alt="Map with a narrow corridor: small enemies walk through it, larger orange ones go around below" width="1792" height="1008" loading="lazy" decoding="async" /></picture><figcaption>Small enemies take the narrow corridor, large ones (orange) don&#39;t fit and walk around it.</figcaption></figure>
<h2 id="step-3-pushing-off-neighbours"><a class="heading-anchor" href="#step-3-pushing-off-neighbours" aria-hidden="true" tabindex="-1">#</a>Step 3: Pushing off neighbours</h2>
<p>A flow field gives a direction but knows nothing about neighbours. The basis is a simplified boids model (the classic flocking rules): each enemy is repelled by neighbours within 0.9 m. Find neighbours in a spatial hash, a grid where every cell holds a list of the units inside it. Rebuild the hash every tick and sort it so the order stays deterministic.</p>
<p>Repulsion alone isn&#39;t enough. At 3,000 enemies, 83.7% of them had their nearest neighbour closer than 0.8 m, and the smallest distance was 4 cm, meaning two enemies were almost inside each other.</p>
<p>A higher separation weight doesn&#39;t fix it. At 6 instead of 1.5 the share of tightly packed enemies dropped to 54.6%, at 12 to 44.3%, at 24 there was no further gain (48.2%). On top of that the crowd starts to jitter, because unbounded pushes from many neighbours add up at once.</p>
<p>Positional correction helps. After moving, push every overlapping pair apart, each by half the overlap, and cap the shift at 0.15 m per tick. Without the cap the correction jitters just like the high weight. With it, 60.8% stay tightly packed, but the smallest distance rises to 28 cm and the crowd stays calm.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Separation only</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/overlap-before.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/overlap-before.png" alt="Crowd at a chokepoint, a large part of the enemies is red, they overlap" width="1792" height="1008" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Overlap correction</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/overlap-after.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/overlap-after.png" alt="The same crowd with overlap correction, red ones remain only right in front of the chokepoint" width="1792" height="1008" loading="lazy" decoding="async" /></picture></div></div><figcaption>Red enemies overlap a neighbour by more than 20 cm. Correction helps in the open, not in front of the chokepoint.</figcaption></figure>
<h2 id="step-4-pushing-off-walls"><a class="heading-anchor" href="#step-4-pushing-off-walls" aria-hidden="true" tabindex="-1">#</a>Step 4: Pushing off walls</h2>
<p>Next to a wall, correction between enemies fails. Moving into walls is forbidden (the move is split per axis and the part going into the wall is dropped), so a pinned enemy has nowhere to go and the push from its neighbour is lost. Out of 938 overlapping enemies, only 35 had any push from a wall.</p>
<p>So add a push away from the nearest blocked cells, in 4 directions and diagonally. Two mistakes you can avoid:</p>
<ul>
<li><strong>Inner corner.</strong> Pushes from both edges and from the corner add up and fling the enemy away. Only add the corner push at an outer corner.</li>
<li><strong>Real walls.</strong> Push off the real walls, not off the walkability of the size class. For a large enemy the &quot;wall&quot; starts a metre before the real one. At a corridor entrance, the corner push cancelled a large enemy&#39;s forward motion and the whole group behind it got stuck.</li>
</ul>
<h2 id="step-5-density-cost-and-slowdown"><a class="heading-anchor" href="#step-5-density-cost-and-slowdown" aria-hidden="true" tabindex="-1">#</a>Step 5: Density cost and slowdown</h2>
<p>Even with a smooth field the whole crowd takes the shortest path tight around the corner, no matter how many enemies are already there. <a href="https://grail.cs.washington.edu/projects/continuum-crowds" target="_blank" rel="noopener">Continuum Crowds</a> (Treuille et al., 2006) offers a solution: cell cost grows with crowd density. Every 10 ticks, count the enemies per cell, smooth the result and recompute the field of one goal. At a crowded corner the field then sends enemies around, through less crowded cells.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">No density</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/density-off.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/density-off.png" alt="Crowd goes around the obstacle tight to the corner in a narrow stream, the red density map is concentrated" width="1792" height="1008" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Density cost</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/density-on.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/density-on.png" alt="Crowd goes around the obstacle in a wide arc, density is spread out" width="1792" height="1008" loading="lazy" decoding="async" /></picture></div></div><figcaption>Same crowd around the same corner. Red is the smoothed crowd density.</figcaption></figure>
<p>The other half is slowing down. An enemy in a dense cell moves slower, down to 15% of its speed. Measured on 2,250 enemies: in sparse cells they moved at 94% of their speed on average, in medium density at 71%, in a jam at 20%. Let the slowdown start early: with a free-density threshold of 0.6 enemies per cell instead of 1, the share of tightly packed enemies in the chokepoint fell to 36.7%.</p>
<p>A practical tip: allocate arrays created at runtime (such as the density map) before a job uses them for the first time. A job scheduled with an array that doesn&#39;t exist yet crashes the Unity Editor in native code, without an exception.</p>
<h2 id="step-6-jams-at-a-chokepoint-wait-don-t-push"><a class="heading-anchor" href="#step-6-jams-at-a-chokepoint-wait-don-t-push" aria-hidden="true" tabindex="-1">#</a>Step 6: Jams at a chokepoint: wait, don&#39;t push</h2>
<p>When there&#39;s no room to spread, the chokepoint remains. The simplest idea is to drop the velocity component towards a neighbour the enemy is touching. The ones at the back then stop instead of stepping aside and keep the jam alive.</p>
<p>Stepping aside, for example by 35° to the side with fewer neighbours, is better, but the crowd starts to flicker. Smoothing the heading and lowering the turn speed (180 instead of 360 °/s) only dampens the symptom. The cause is elsewhere: with balanced density the enemy picks a different side every tick. Hysteresis, a bias towards the previous decision, fixes it: the side from the previous tick gets a bonus of 3 neighbours in the comparison.</p>
<p>The final solution combines a contact constraint with three states: moving, sidestepping, waiting. The velocity component into a touching neighbour is dropped, the sideways one stays. Whoever has neither a free side nor room ahead waits and doesn&#39;t push.</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">for each enemy, every tick:
    velocity = flow field direction × speed × density slowdown
    velocity += push from neighbours
    if touching a neighbour ahead:
        if a side is free: sidestep (side with hysteresis)
        else: wait
    drop the velocity component into touching neighbours
    move, drop the component into walls
then for everyone:
    push overlapping pairs apart (half the overlap, at most 0.15 m)
    push off walls</code></pre>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/crowd-states.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/crowd-states.png" alt="Crowd in front of a chokepoint, white dots move, red ones wait, past the chokepoint a spaced line moves on" width="1792" height="1008" loading="lazy" decoding="async" /></picture><figcaption>In front of the chokepoint: white ones move, red ones wait. Past it, a spaced line moves on.</figcaption></figure>
<p>Watch out for three things:</p>
<ul>
<li><strong>Patience.</strong> When two enemies meet head-on, let the one with the higher ID wait. A chain of neighbours can keep it blocked for thousands of ticks, though, so after a waiting limit it tries an emergency sidestep.</li>
<li><strong>Zero velocity.</strong> An enemy whose desired velocity is zero never leaves the &quot;waiting&quot; state and stays standing in the open after a wave. An enemy that doesn&#39;t want to move should always be &quot;moving&quot;.</li>
<li><strong>Narrow cone.</strong> Look for a neighbour ahead within a narrow cone and with a short probe. At 45°, the settings sent 55 to 60% of the crowd into waiting or sidestepping even in the open; at 30° with a shorter probe, 41%.</li>
</ul>
<h2 id="step-7-overtaking-large-enemies"><a class="heading-anchor" href="#step-7-overtaking-large-enemies" aria-hidden="true" tabindex="-1">#</a>Step 7: Overtaking large enemies</h2>
<p>Enemy types of different sizes and speeds bring more cases. Our large, slow enemy has a crowd radius of 1.25 m and walks at 1.1 m/s. A fast enemy behind it couldn&#39;t overtake: the side probes diagonally ahead were always closer to the large one than the sum of radii, both sides came out blocked and the fast enemy waited. The fix: the probe only counts the neighbour being passed on that neighbour&#39;s own side. A neighbour straight ahead blocks neither side.</p>
<p>The large enemy in turn kicks small ones out of its way to the sides, within ±45° in front of it. Don&#39;t kick those moving in its direction faster than it wants to go, or it will also kick the ones currently overtaking it.</p>
<h2 id="step-8-slots-around-the-attack-target"><a class="heading-anchor" href="#step-8-slots-around-the-attack-target" aria-hidden="true" tabindex="-1">#</a>Step 8: Slots around the attack target</h2>
<p>If enemies attack in melee, they need room around the target. Our tower has 24 slots around its 5 × 5 m footprint, each enemy takes one and the rest wait in a queue.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/tower-slots.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/tower-slots.png" alt="Tower with 24 slots around its edge, most occupied, a yellow path circles the tower in an arc to a free slot" width="1408" height="960" loading="lazy" decoding="async" /></picture><figcaption>Occupied slots attack, free ones are dashed. A new enemy circles the tower in an arc to a free slot, a queue waits behind the full side.</figcaption></figure>
<ul>
<li><strong>Circle around.</strong> If you only offer a slot when a straight line leads to it, the rear slots stay empty, because from the front the line to them crosses the tower. Let the enemy circle the tower in an arc to a free slot.</li>
<li><strong>No eviction.</strong> Don&#39;t evict an enemy on its way to a slot in favour of another requester. It starts chains of evictions.</li>
<li><strong>No pushing at the slot.</strong> An enemy arriving next to an occupied slot gets pushed by its neighbour and ends up 13 cm from its target or completely off it. Enemies in a slot, and those within 0.5 m of it, don&#39;t push each other.</li>
</ul>
<h2 id="simulation-and-rendering-performance"><a class="heading-anchor" href="#simulation-and-rendering-performance" aria-hidden="true" tabindex="-1">#</a>Simulation and rendering performance</h2>
<p>A benchmark in the Unity Editor (M3 Pro, around 9,600 simple enemies, two chokepoints) gave 90 FPS at 1440p and 100 at 1080p. Resolution barely changes FPS, time acceleration does: at 10× it drops to 20 FPS, because each frame has to run ten simulation steps. At normal speed the simulation has headroom.</p>
<p>With a detailed enemy model instead of a capsule, the bottleneck moves to rendering:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Variant</th>
<th align="right">On screen</th>
<th align="right">Triangles per model</th>
<th align="right">Median FPS</th>
</tr>
</thead>
<tbody><tr>
<td>Capsule</td>
<td align="right">2,612</td>
<td align="right">844</td>
<td align="right">197</td>
</tr>
<tr>
<td>Detailed model, shadows on</td>
<td align="right">2,976</td>
<td align="right">3,578</td>
<td align="right">48</td>
</tr>
<tr>
<td>Detailed model, shadows off</td>
<td align="right">2,976</td>
<td align="right">3,578</td>
<td align="right">85</td>
</tr>
<tr>
<td>Simplified model, shadows on</td>
<td align="right">2,945</td>
<td align="right">878</td>
<td align="right">335</td>
</tr>
</tbody></table>
</div>
<p>In the simulation, three things pay off:</p>
<ul>
<li><strong>Two neighbour passes.</strong> The cheap one (contacts, blocking) always runs, the expensive one (side probes, side density) only for blocked enemies, a minority in free flow. Compare distances squared, without a square root.</li>
<li><strong>Simple sorting.</strong> Sorting the spatial hash synchronously took 0.37, 1.49, 3.16 and 8.46 ms at 3,000, 10,000, 20,000 and 50,000 entries, the job version 0.18, 0.93, 3.12 and 15.53 ms. The job isn&#39;t consistently faster, so simple sorting is enough.</li>
<li><strong>State hash.</strong> At ticks 600 and 1800, hash positions and health. A refactor must give the same hash, a behaviour change alters it and you can explain why. This works thanks to the deterministic order in the spatial hash.</li>
</ul>
<h2 id="limits-of-the-approach"><a class="heading-anchor" href="#limits-of-the-approach" aria-hidden="true" tabindex="-1">#</a>Limits of the approach</h2>
<ul>
<li>Two full crowds going in opposite directions through one chokepoint get stuck for good. That&#39;s a limit of a model without physics.</li>
<li>The numbers come from the Unity Editor on an M3 Pro. We don&#39;t have measurements on a Steam Deck or in a real build yet, only an estimate from public CPU benchmarks.</li>
</ul>
<p>If you want to try your own crowd, the <a href="https://docs.unity3d.com/Packages/com.unity.entities@6.5/manual/" target="_blank" rel="noopener">Unity Entities documentation</a> is a good start. A similar path from the simplest solution to a working one is described in our post on the <a href="https://smallwater.eu/en/blog/sudoku-generator/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/unity-dots-horde-navigation/" data-ga-destination="/en/blog/sudoku-generator/" data-ga-placement="inline">sudoku generator</a>. How to throw enemies in such a crowd around with an explosion without an expensive ragdoll for each one is covered in <a href="https://smallwater.eu/en/blog/fake-ragdoll-unity-dots/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/unity-dots-horde-navigation/" data-ga-destination="/en/blog/fake-ragdoll-unity-dots/" data-ga-placement="inline">how to fake ragdolls in Unity DOTS</a>. If you&#39;re working on a similar simulation in Unity and want help, <a href="https://smallwater.eu/en/#kontakt">get in touch</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to generate a word search with a hidden message</title>
      <link>https://smallwater.eu/en/blog/word-search-generator/</link>
      <guid isPermaLink="true">https://smallwater.eu/en/blog/word-search-generator/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Crosswords &amp; Puzzles</category>
      <description>A word search generator with a hidden message, step by step: dictionary, word lengths, filling the grid without backtracking and success rates by grid size.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/cover.webp" /><img src="https://smallwater.eu/blog/word-search-generator/cover.jpg" alt="A 10×10 word search with coloured word highlights and the hidden message in coral cells" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Contents"><p class="post-toc-title">Contents</p><ol><li><a href="#grid-rules" data-ga="blog_toc_clicked" data-ga-heading-id="grid-rules">Grid rules</a></li><li><a href="#step-1-prepare-the-dictionary" data-ga="blog_toc_clicked" data-ga-heading-id="step-1-prepare-the-dictionary">Step 1: Prepare the dictionary</a></li><li><a href="#step-2-pick-the-hidden-message" data-ga="blog_toc_clicked" data-ga-heading-id="step-2-pick-the-hidden-message">Step 2: Pick the hidden message</a></li><li><a href="#why-not-to-start-with-the-message" data-ga="blog_toc_clicked" data-ga-heading-id="why-not-to-start-with-the-message">Why not to start with the message</a></li><li><a href="#step-3-fill-the-grid-until-exactly-n-holes-are-left" data-ga="blog_toc_clicked" data-ga-heading-id="step-3-fill-the-grid-until-exactly-n-holes-are-left">Step 3: Fill the grid until exactly N holes are left</a></li><li><a href="#step-4-draw-word-lengths-sensibly" data-ga="blog_toc_clicked" data-ga-heading-id="step-4-draw-word-lengths-sensibly">Step 4: Draw word lengths sensibly</a></li><li><a href="#step-5-pour-the-message-into-the-holes" data-ga="blog_toc_clicked" data-ga-heading-id="step-5-pour-the-message-into-the-holes">Step 5: Pour the message into the holes</a></li><li><a href="#when-a-grid-fails" data-ga="blog_toc_clicked" data-ga-heading-id="when-a-grid-fails">When a grid fails</a></li><li><a href="#success-rate-and-speed-by-grid-size" data-ga="blog_toc_clicked" data-ga-heading-id="success-rate-and-speed-by-grid-size">Success rate and speed by grid size</a></li><li><a href="#tip-the-same-grid-on-every-run" data-ga="blog_toc_clicked" data-ga-heading-id="tip-the-same-grid-on-every-run">Tip: the same grid on every run</a></li></ol></nav><p>To generate a word search with a hidden message, drop random dictionary words into an empty grid until exactly as many cells stay empty as the message has letters. Then fill those cells with the message, left to right, top to bottom. On a phone this takes a few milliseconds and needs no backtracking, that is, no stepping back to earlier decisions when the generator gets stuck.</p>
<p>A word search is a grid of letters in which the player finds and crosses out words from a list. The hidden message is the text spelled by the letters that remain after all words are crossed out. So the generator has to build a grid that is completely full and leaves exactly the message once the words are gone. The process below is how the word searches in our crosswords and word search app (<a href="https://smallwater.eu/crosswords-ios">App Store</a>, <a href="https://smallwater.eu/crosswords-android">Google Play</a>) are generated.</p>
<h2 id="grid-rules"><a class="heading-anchor" href="#grid-rules" aria-hidden="true" tabindex="-1">#</a>Grid rules</h2>
<p>Before you start, decide what a finished grid must satisfy. In our app:</p>
<ul>
<li>Words run in eight directions: horizontally, vertically and along both diagonals, each of them also backwards.</li>
<li>Two words may share at most one letter.</li>
<li>No word may pass through a cell of the hidden message.</li>
<li>The grid must be full. An empty cell is a bug.</li>
<li>The message is read left to right, top to bottom from all letters that are not crossed out.</li>
</ul>
<h2 id="step-1-prepare-the-dictionary"><a class="heading-anchor" href="#step-1-prepare-the-dictionary" aria-hidden="true" tabindex="-1">#</a>Step 1: Prepare the dictionary</h2>
<p>Split the dictionary into groups by word length once, at the start. Picking a random word of a given length is then a single random index. If you filter the whole dictionary for every word, generation slows down noticeably, since a single grid tries hundreds of words.</p>
<p>Our Slovak dictionary has 16,713 words from 3 to 21 letters long, most of them with seven or eight letters. A few practical tips:</p>
<ul>
<li><strong>Keep proper nouns and abbreviations in the dictionary.</strong> Words like HOUSTON or MAINE will then show up in the grid. Without short and &quot;odd&quot; words the grid can&#39;t be packed densely enough.</li>
<li><strong>Filter profanity with a separate list</strong> that can be switched off when the player wants adult content.</li>
<li><strong>With several languages, load only the one being played.</strong> The English dictionary has more than 414,000 words. Loading all four languages at app startup freezes a weaker phone for as long as 11 seconds.</li>
</ul>
<h2 id="step-2-pick-the-hidden-message"><a class="heading-anchor" href="#step-2-pick-the-hidden-message" aria-hidden="true" tabindex="-1">#</a>Step 2: Pick the hidden message</h2>
<p>The message should have at least as many letters as the grid side, and at most four more. A shorter one gets lost in the grid, a longer one needs too many holes.</p>
<p>In our app the hidden message is the punchline of a joke. The player crosses out words and reads the ending of the joke from the remaining letters. We build the message from the end of the joke, adding whole words from the back until its length falls into that range.</p>
<p>The images below use the joke &quot;Two snakes walk into a bar. Turns out they were lizards.&quot; on a 10×10 grid. The hidden message is WERELIZARDS, 11 letters. The words in the images come from the English dictionary narrowed down to common words, so they are readable.</p>
<h2 id="why-not-to-start-with-the-message"><a class="heading-anchor" href="#why-not-to-start-with-the-message" aria-hidden="true" tabindex="-1">#</a>Why not to start with the message</h2>
<p>The first idea is to do what you would do with a pencil: scatter the message letters over random cells in reading order, then build words around them.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/v1-seed.webp" /><img src="https://smallwater.eu/blog/word-search-generator/v1-seed.png" alt="Empty 10×10 grid with eleven randomly scattered message letters" width="976" height="986" loading="lazy" decoding="async" /></picture><figcaption>Message letters scattered over the grid before any words.</figcaption></figure>
<p>That looks fine until the end. The generator picks an empty cell, tries to fit a word through it in every direction and offset, and once the grid gets dense, isolated holes remain. A single hole between two message letters can&#39;t be filled by anything, because the shortest word has three letters and no word may cross the message.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/v1-stuck.webp" /><img src="https://smallwater.eu/blog/word-search-generator/v1-stuck.png" alt="Almost full grid with three empty cells that no word can fill" width="976" height="986" loading="lazy" decoding="async" /></picture><figcaption>After 10,000 attempts, three holes are left that no word can fill.</figcaption></figure>
<p>On a sample of 200 grids of 10×10, this approach produced a full grid in only 28 % of cases, with 1 to 2 holes left on average. Patching them afterwards is hard. So don&#39;t put the message into the grid until the words are done.</p>
<h2 id="step-3-fill-the-grid-until-exactly-n-holes-are-left"><a class="heading-anchor" href="#step-3-fill-the-grid-until-exactly-n-holes-are-left" aria-hidden="true" tabindex="-1">#</a>Step 3: Fill the grid until exactly N holes are left</h2>
<p>Filling is a simple greedy algorithm: each word is placed as soon as it fits and is never removed. The generator tracks a single number: how many cells are still empty. It places a word only if that would not leave fewer empty cells than the message has letters (N).</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">empty = rows × cols
while empty &gt; N and attempts &lt; 2000:
    word = random word of the chosen length
    repeat at most 50 times:
        start = random cell, direction = random one of 8
        if the word fits the grid rules:
            new = number of empty cells the word would fill
            if empty - new &gt;= N:
                place the word
                empty = empty - new
                continue with the next word
if empty != N: throw the grid away and start over</code></pre>
<p>The first words land freely in the empty grid:</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/step-1.webp" /><img src="https://smallwater.eu/blog/word-search-generator/step-1.png" alt="Grid with the first three words: one horizontal and two vertical" width="976" height="986" loading="lazy" decoding="async" /></picture><figcaption>After three words. Each word has its own colour.</figcaption></figure>
<p>As the grid fills up, words start sharing letters. That is welcome, since every shared letter is one cell less to fill:</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/step-2.webp" /><img src="https://smallwater.eu/blog/word-search-generator/step-2.png" alt="Grid with thirteen words, more than a third of the cells empty" width="976" height="986" loading="lazy" decoding="async" /></picture><figcaption>Thirteen words, 40 empty cells.</figcaption></figure>
<p>When the number of empty cells drops to N, filling stops. Where the holes are doesn&#39;t matter. They can be scattered anywhere, because the message doesn&#39;t have to form any path through them:</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/step-3.webp" /><img src="https://smallwater.eu/blog/word-search-generator/step-3.png" alt="Grid with twenty-two words and eleven empty cells with dashed outlines" width="976" height="986" loading="lazy" decoding="async" /></picture><figcaption>Exactly 11 empty cells left, as many as WERELIZARDS has letters.</figcaption></figure>
<h2 id="step-4-draw-word-lengths-sensibly"><a class="heading-anchor" href="#step-4-draw-word-lengths-sensibly" aria-hidden="true" tabindex="-1">#</a>Step 4: Draw word lengths sensibly</h2>
<p>Long words make a word search interesting, short ones get it finished. So draw the length from two normal distributions (you can get normally distributed random numbers from the <a href="https://en.wikipedia.org/wiki/Box%E2%80%93Muller_transform" target="_blank" rel="noopener">Box-Muller transform</a>, for example):</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Probability</th>
<th>Word length</th>
</tr>
</thead>
<tbody><tr>
<td>50 %</td>
<td>around 6 letters, standard deviation 2</td>
</tr>
<tr>
<td>30 %</td>
<td>around 5 letters, standard deviation 1</td>
</tr>
<tr>
<td>20 %</td>
<td>3 letters</td>
</tr>
</tbody></table>
</div>
<p>The length is always at least 3 and at most the grid side. Add two rules, without which the generator gets stuck at the end:</p>
<ul>
<li><strong>Force the exact length at the end.</strong> When 5 or fewer cells are missing, look for a word of exactly the missing length (at least 3). Otherwise the generator keeps trying six-letter words that no longer fit.</li>
<li><strong>Ration three-letter words.</strong> The first three attempts at a grid may not contain any, attempts 4 to 6 may contain one, the next three two, and so on. Three-letter dictionary entries are often abbreviations like DNA or CPU and look cheap in a word search. Allow them only when there is no other way.</li>
</ul>
<h2 id="step-5-pour-the-message-into-the-holes"><a class="heading-anchor" href="#step-5-pour-the-message-into-the-holes" aria-hidden="true" tabindex="-1">#</a>Step 5: Pour the message into the holes</h2>
<p>Walk the grid left to right, top to bottom and write the next message letter into every empty cell. Since there are exactly N holes, the message fits with nothing left over.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/step-4.webp" /><img src="https://smallwater.eu/blog/word-search-generator/step-4.png" alt="The same grid, empty cells filled with the message letters numbered 1 to 11" width="976" height="986" loading="lazy" decoding="async" /></picture><figcaption>Message letters in reading order. The player finds them as the last letters not crossed out.</figcaption></figure>
<p>This is all the player sees:</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/final.webp" /><img src="https://smallwater.eu/blog/word-search-generator/final.png" alt="Finished 10×10 word search without highlights" width="976" height="986" loading="lazy" decoding="async" /></picture><figcaption>The finished grid as the player sees it.</figcaption></figure>
<aside class="app-box app-box-callout"><img class="app-box-img" src="https://smallwater.eu/images/crosswords/1-crosswords.jpg" alt="" width="698" height="1516" loading="lazy" decoding="async" /><div class="app-box-body"><p class="app-box-eyebrow">Try it</p><h2 class="app-box-title">Crosswords &amp; Puzzles</h2><p>Crosswords, word search, sudoku and nonograms in one offline app. How the puzzles are generated and what&#39;s new.</p><div class="app-box-links"><a class="btn btn-primary" href="https://smallwater.eu/crosswords-ios" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="crosswords" data-ga-destination="/crosswords-ios" data-ga-app="crosswords" data-ga-store="app_store">App Store</a><a class="btn btn-ghost" href="https://smallwater.eu/crosswords-android" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="crosswords" data-ga-destination="/crosswords-android" data-ga-app="crosswords" data-ga-store="google_play">Google Play</a></div></div></aside>
<h2 id="when-a-grid-fails"><a class="heading-anchor" href="#when-a-grid-fails" aria-hidden="true" tabindex="-1">#</a>When a grid fails</h2>
<p>If the generator doesn&#39;t reach exactly N empty cells within 2,000 attempts, throw the whole grid away and start over, at most 100 times. No hole patching. It is crude, but for grids up to 12×12 it is more than enough. Bigger grids need more attempts, as the table below shows.</p>
<h2 id="success-rate-and-speed-by-grid-size"><a class="heading-anchor" href="#success-rate-and-speed-by-grid-size" aria-hidden="true" tabindex="-1">#</a>Success rate and speed by grid size</h2>
<p>The generator ran on 300 grids per size, with real jokes and the Slovak dictionary (Node.js 22 on a server CPU, a phone will be slower; the images use the English dictionary, the numbers the Slovak one, because it is smaller and so a stricter test). &quot;First try&quot; means the first round of filling ended with a full grid.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Grid</th>
<th align="right">First try</th>
<th align="right">Avg. attempts</th>
<th align="right">Median time</th>
<th align="right">Words in grid</th>
<th align="right">Diagonal share</th>
</tr>
</thead>
<tbody><tr>
<td>8×8</td>
<td align="right">78 %</td>
<td align="right">1.30</td>
<td align="right">1.2 ms</td>
<td align="right">12.7</td>
<td align="right">24 %</td>
</tr>
<tr>
<td>10×10</td>
<td align="right">70 %</td>
<td align="right">1.42</td>
<td align="right">2.2 ms</td>
<td align="right">20.4</td>
<td align="right">27 %</td>
</tr>
<tr>
<td>12×12</td>
<td align="right">57 %</td>
<td align="right">1.73</td>
<td align="right">4.7 ms</td>
<td align="right">30.1</td>
<td align="right">29 %</td>
</tr>
<tr>
<td>15×15</td>
<td align="right">21 %</td>
<td align="right">5.46</td>
<td align="right">20 ms</td>
<td align="right">48.7</td>
<td align="right">33 %</td>
</tr>
</tbody></table>
</div>
<p>What this means for your own generator:</p>
<ul>
<li><strong>The average word has 4.7 letters</strong>, although the target is around 6. Long words simply fail to fit into a dense grid more often.</li>
<li><strong>About 9 to 10 % of cells are shared by two words.</strong> You don&#39;t need to tune this, it comes out of the &quot;at most one shared letter&quot; rule.</li>
<li><strong>You will get fewer diagonals than you expect.</strong> The direction is drawn uniformly from eight, so diagonals should make up half of the words. In a small grid, though, a diagonal word falls off the edge much more often. On 8×8 diagonals are 24 % of words, on 15×15 already 33 %. If you want more diagonals, give them a higher weight when drawing the direction.</li>
</ul>
<h2 id="tip-the-same-grid-on-every-run"><a class="heading-anchor" href="#tip-the-same-grid-on-every-run" aria-hidden="true" tabindex="-1">#</a>Tip: the same grid on every run</h2>
<p>For store screenshots you need the exact same grid every time. In that mode, replace the usual random number generator with a simple linear congruential generator and a fixed seed (derived from the language, for example). If you keep all arithmetic within the range JavaScript numbers represent exactly, the result is identical on every device. The images in this article were generated with the same trick.</p>
<p>If you are solving a similar problem, generating puzzles or need a mobile app, <a href="https://smallwater.eu/en/#kontakt">get in touch</a>. We also post short clips from development on <a href="https://www.youtube.com/@smallwaterinc" target="_blank" rel="noopener">YouTube</a>. The same step-by-step approach is used in <a href="https://smallwater.eu/en/blog/sudoku-generator/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/word-search-generator/" data-ga-destination="/en/blog/sudoku-generator/" data-ga-placement="inline">how to generate a sudoku with a unique solution</a>. For crosswords instead of word searches, see <a href="https://smallwater.eu/en/blog/swedish-crossword-generator/" data-ga="blog_related_link_clicked" data-ga-source-path="/en/blog/word-search-generator/" data-ga-destination="/en/blog/swedish-crossword-generator/" data-ga-placement="inline">how to generate an arrowword with a hidden message</a>.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
