<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Milaan Games</title>
    <description>The latest articles on DEV Community by Milaan Games (@milaangames).</description>
    <link>https://dev.to/milaangames</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4242074%2Ffbeb7ae5-bc98-44a6-bcd0-3845079d2b8d.png</url>
      <title>DEV Community: Milaan Games</title>
      <link>https://dev.to/milaangames</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/milaangames"/>
    <language>en</language>
    <item>
      <title>How we made 300 block puzzle levels provably solvable</title>
      <dc:creator>Milaan Games</dc:creator>
      <pubDate>Sun, 15 Nov 2026 01:38:40 +0000</pubDate>
      <link>https://dev.to/milaangames/how-we-made-300-block-puzzle-levels-provably-solvable-1hpi</link>
      <guid>https://dev.to/milaangames/how-we-made-300-block-puzzle-levels-provably-solvable-1hpi</guid>
      <description>&lt;p&gt;Our block puzzle Blokito has an Adventure mode with 300 designed levels: 10 chapters of 30. A puzzle level that can't be won is the worst bug you can ship. The player blames themselves, retries, and quits. With 300 levels, nobody on a small team can replay every one after each rules change. So every level ships with a recorded solution, and our test suite replays that solution through the real game engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a level is
&lt;/h2&gt;

&lt;p&gt;Each level is a data file with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an immutable ID and version, its chapter and index, and a difficulty tag&lt;/li&gt;
&lt;li&gt;the starting 8×8 board (some cells pre-filled, never a full row or column) plus item markers on cells&lt;/li&gt;
&lt;li&gt;one objective: either collect item quotas (stars, diamonds, rubies, emeralds) or clear every pre-placed block of one color&lt;/li&gt;
&lt;li&gt;a move limit, the piece batches and an RNG seed&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;witness&lt;/strong&gt;: the winning sequence, recorded as batch slot plus anchor cell for every move&lt;/li&gt;
&lt;li&gt;a content hash, so edited content can be told apart from the version that was tested&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The witness is the key part. It is a concrete solution, not a claim that one exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The validator
&lt;/h2&gt;

&lt;p&gt;The validator loads each level, feeds the witness moves into the same pure-domain engine the game uses (placement, simultaneous line clears, scoring and combo, item collection, move countdown, win check), and asserts that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;every witness move is legal at that moment&lt;/li&gt;
&lt;li&gt;the objective completes within the move limit&lt;/li&gt;
&lt;li&gt;no ad, revive or other help is needed&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because it runs the real rules code, not a copy, a rules change that breaks a level fails the tests right away. The engine has no scene, file or tween dependencies, so the tests run headless.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not prove
&lt;/h2&gt;

&lt;p&gt;A witness proves that &lt;strong&gt;at least one&lt;/strong&gt; winning path exists. It does not prove that the level is fun, that the difficulty is right, or that a reasonable player will find the path. We still playtest and balance by hand. The validator removes one class of bug (impossible levels) so playtesting time goes to feel instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pacing 300 levels
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Levels 1–10 teach one idea at a time.&lt;/li&gt;
&lt;li&gt;After that, easy, medium and hard levels alternate, so a hard level is always followed by a breather.&lt;/li&gt;
&lt;li&gt;Level 30 of each chapter is a challenge level.&lt;/li&gt;
&lt;li&gt;Every first win reveals one of 30 pieces of the chapter's picture. Replays don't give extra rewards.&lt;/li&gt;
&lt;li&gt;Progress is stored as the set of completed level IDs. Unlocks and revealed pieces are derived from that set, never kept as separate counters that could drift apart. Adding level 301 later doesn't renumber anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways for your own puzzle game
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Store a solution next to every handmade level, and replay it in your tests with your real engine.&lt;/li&gt;
&lt;li&gt;Keep the rules engine free of scene and UI code so it can run headless.&lt;/li&gt;
&lt;li&gt;Version levels immutably and hash their content.&lt;/li&gt;
&lt;li&gt;Derive progress from one source of truth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see how it plays, Blokito and our other games are at &lt;a href="https://lamian.dev" rel="noopener noreferrer"&gt;https://lamian.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>godot</category>
      <category>testing</category>
    </item>
    <item>
      <title>Frame-perfect gameplay videos from Godot 4 without a screen recorder</title>
      <dc:creator>Milaan Games</dc:creator>
      <pubDate>Fri, 13 Nov 2026 03:21:07 +0000</pubDate>
      <link>https://dev.to/milaangames/frame-perfect-gameplay-videos-from-godot-4-without-a-screen-recorder-52ob</link>
      <guid>https://dev.to/milaangames/frame-perfect-gameplay-videos-from-godot-4-without-a-screen-recorder-52ob</guid>
      <description>&lt;p&gt;We make small mobile puzzle games in Godot, and we need a steady supply of vertical gameplay clips for YouTube Shorts and TikTok. Screen recorders gave us dropped frames, desktop notifications in the shot and a different result on every take. So we moved the camera inside the engine. The game renders into an offscreen viewport, and every frame is saved to disk. Then ffmpeg turns the frames into a video.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Run Godot with a fixed timestep: &lt;code&gt;--fixed-fps 30 --disable-vsync&lt;/code&gt;. The game advances exactly 1/30 s per frame no matter how slow the capture is, so animations never stutter.&lt;/li&gt;
&lt;li&gt;Start the game from a &lt;code&gt;SceneTree&lt;/code&gt; script (&lt;code&gt;--script res://tools/capture.gd&lt;/code&gt;) instead of the main scene. The script builds the scene, drives it and quits when done.&lt;/li&gt;
&lt;li&gt;Put the real game scene inside a &lt;code&gt;SubViewport&lt;/code&gt; at the output size (for example 1080×1920), with &lt;code&gt;render_target_update_mode = SubViewport.UPDATE_ALWAYS&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On each frame, call &lt;code&gt;RenderingServer.force_draw()&lt;/code&gt; and save &lt;code&gt;viewport.get_texture().get_image()&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Minimal capture script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="n"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;SceneTree&lt;/span&gt;

&lt;span class="n"&gt;const&lt;/span&gt; &lt;span class="no"&gt;OUT&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s2"&gt;"user://capture"&lt;/span&gt;
&lt;span class="n"&gt;const&lt;/span&gt; &lt;span class="no"&gt;FRAMES&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;  &lt;span class="c1"&gt;# 10 s at 30 fps&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SubViewport&lt;/span&gt;

&lt;span class="n"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nc"&gt;DirAccess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;make_dir_recursive_absolute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;OUT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;viewport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SubViewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Vector2i&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;render_target_update_mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SubViewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;UPDATE_ALWAYS&lt;/span&gt;
    &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"res://scenes/main.tscn"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instantiate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_deferred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="no"&gt;FRAMES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;process_frame&lt;/span&gt;
        &lt;span class="nc"&gt;RenderingServer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;force_draw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_texture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save_jpg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"%s/f_%05d.jpg"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;OUT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;quit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;godot --path . --fixed-fps 30 --disable-vsync --audio-driver Dummy --script res://tools/capture.gd
ffmpeg -framerate 30 -i f_%05d.jpg -c:v libx264 -pix_fmt yuv420p -movflags +faststart out.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Driving real input
&lt;/h2&gt;

&lt;p&gt;A recording of an idle menu is boring. Our capture script calls the same functions the drag-and-drop UI uses (begin drag, move, drop) with eased positions, so the clip shows real input, real line clears and the real animations. Every placement and clear is logged to a JSON file with its frame number. A small Python script then adds captions and overlays and lines up sound effects with the logged frames.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that bit us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Saves.&lt;/strong&gt; The capture plays the game for real, so it would write to the player's save file. We run it on a throwaway copy of the project with private save paths, and the script checks that real saves are never touched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Splash screens and first-run tutorials.&lt;/strong&gt; Skip them in code before you start recording. Otherwise the first seconds of every clip show the same intro.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Determinism.&lt;/strong&gt; Seed the piece generator so a "good" run can be re-recorded exactly when you change a caption or the music.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk.&lt;/strong&gt; 30 fps at 1080×1920 is a lot of JPGs. Delete the frames after encoding.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;Every run produces the same stutter-free footage, so re-editing never needs a new recording. We use the same script for Play Store screenshots in several languages and screen sizes.&lt;/p&gt;

&lt;p&gt;The clips end up in our Shorts for Blokito, a block puzzle with 300 Adventure levels. If you want to see the output, our games and free browser games are at &lt;a href="https://lamian.dev" rel="noopener noreferrer"&gt;https://lamian.dev&lt;/a&gt;. Questions about the script are welcome in the comments.&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
