<?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: saltmire</title>
    <description>The latest articles on DEV Community by saltmire (@saltmire).</description>
    <link>https://dev.to/saltmire</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%2F4021918%2Fe3d9003f-48ff-4d01-be1e-f805e2ae92fe.png</url>
      <title>DEV Community: saltmire</title>
      <link>https://dev.to/saltmire</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/saltmire"/>
    <language>en</language>
    <item>
      <title>JSON vs ConfigFile vs Resource in Godot 4 — which save format and when</title>
      <dc:creator>saltmire</dc:creator>
      <pubDate>Sat, 17 Oct 2026 12:28:30 +0000</pubDate>
      <link>https://dev.to/saltmire/json-vs-configfile-vs-resource-in-godot-4-which-save-format-and-when-31a8</link>
      <guid>https://dev.to/saltmire/json-vs-configfile-vs-resource-in-godot-4-which-save-format-and-when-31a8</guid>
      <description>&lt;p&gt;Godot 4 gives you three sanctioned ways to write a save file, and the docs describe each&lt;br&gt;
one without ever telling you which to pick. So you pick the first one you read about,&lt;br&gt;
ship it, and discover the trade-off six months later when a player's &lt;code&gt;Vector2&lt;/code&gt; comes back&lt;br&gt;
as a &lt;code&gt;Dictionary&lt;/code&gt; or an old save crashes the new build. Here's what each format actually&lt;br&gt;
costs.&lt;/p&gt;
&lt;h2&gt;
  
  
  JSON: portable, readable, type-lossy
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FileAccess&lt;/code&gt; plus &lt;code&gt;JSON&lt;/code&gt; is the path most tutorials take, because it's the one that&lt;br&gt;
looks like saving in every other language.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;save_game&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Dictionary&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;var&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FileAccess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user://save.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FileAccess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WRITE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;load_game&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;FileAccess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user://save.json"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FileAccess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user://save.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FileAccess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;READ&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_as_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kt"&gt;Dictionary&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch is that JSON has no idea what a Godot type is. It knows objects, arrays,&lt;br&gt;
strings, numbers, booleans and null — that's the whole list. Three consequences bite in&lt;br&gt;
practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Numbers come back as floats.&lt;/strong&gt; Save &lt;code&gt;level = 3&lt;/code&gt;, load &lt;code&gt;3.0&lt;/code&gt;. Anything you feed to an
array index or a &lt;code&gt;match&lt;/code&gt; on an int needs an explicit &lt;code&gt;int()&lt;/code&gt; on the way out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dictionary keys become strings.&lt;/strong&gt; &lt;code&gt;{1: "sword"}&lt;/code&gt; saves and loads as &lt;code&gt;{"1": "sword"}&lt;/code&gt;.
Inventory slots keyed by int are the usual victim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vector2, Color and friends don't survive.&lt;/strong&gt; You convert them by hand going in and
coming out, every field, forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you get in exchange is real: the file is plain text any tool can read, nothing in it&lt;br&gt;
can execute, and you can parse it outside Godot — a web leaderboard, a Python script, a&lt;br&gt;
save editor you write in an afternoon.&lt;/p&gt;
&lt;h2&gt;
  
  
  ConfigFile: Variants that round-trip, in an INI file
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ConfigFile&lt;/code&gt; stores sections and keys, and it keeps Godot's own types.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;save_game&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;var&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ConfigFile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"player"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"player"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"hp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"world"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"unlocked"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"cave"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"ridge"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user://save.cfg"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;load_game&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;var&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ConfigFile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user://save.cfg"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"player"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ZERO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;hp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"player"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"hp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Vector2&lt;/code&gt; goes in and &lt;code&gt;Vector2&lt;/code&gt; comes out. &lt;code&gt;3&lt;/code&gt; stays an &lt;code&gt;int&lt;/code&gt;. Arrays and Dictionaries of&lt;br&gt;
Variants work too, and &lt;code&gt;get_value()&lt;/code&gt; takes a default as its third argument — which quietly&lt;br&gt;
solves the "player has an old save without this key" problem that forces try/except&lt;br&gt;
gymnastics in JSON.&lt;/p&gt;

&lt;p&gt;The limits: it stores Variants, not objects, so a custom class instance isn't going in.&lt;br&gt;
It's flat by nature — two levels, section and key — so deeply nested state gets encoded as&lt;br&gt;
one big Dictionary value and you've lost the readability you came for. And the format is&lt;br&gt;
Godot's own, so nothing outside the engine parses it for free.&lt;/p&gt;
&lt;h2&gt;
  
  
  Resource: typed, autocompleted, and the one with a security warning
&lt;/h2&gt;

&lt;p&gt;A custom &lt;code&gt;Resource&lt;/code&gt; turns your save into a class with real fields.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;class_name&lt;/span&gt; &lt;span class="n"&gt;SaveData&lt;/span&gt; &lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Resource&lt;/span&gt;

&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;hp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ZERO&lt;/span&gt;
&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;unlocked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;save_game&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SaveData&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="n"&gt;ResourceSaver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"user://save.tres"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;load_game&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;SaveData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ResourceLoader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user://save.tres"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;SaveData&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user://save.tres"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;SaveData&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Typos become editor errors instead of runtime nils, and you get autocomplete on your own&lt;br&gt;
save. It is by far the nicest to write.&lt;/p&gt;

&lt;p&gt;Two things to know before you ship it. First, &lt;strong&gt;resource files can reference scripts, and&lt;br&gt;
loading one runs them&lt;/strong&gt; — the Godot docs say outright not to load resources from untrusted&lt;br&gt;
sources. A save file in &lt;code&gt;user://&lt;/code&gt; is a file the player (or anything else on their machine)&lt;br&gt;
can swap. If your game ever accepts a save from elsewhere — cloud sync, a Discord&lt;br&gt;
"try my seed", a modding scene — that's a code-execution path, not just a cheating one.&lt;br&gt;
Second, the file is tied to your script: rename the class, move the file, or drop an&lt;br&gt;
&lt;code&gt;@export&lt;/code&gt; and old saves stop loading cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;JSON&lt;/th&gt;
&lt;th&gt;ConfigFile&lt;/th&gt;
&lt;th&gt;Resource (.tres)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Keeps &lt;code&gt;int&lt;/code&gt; vs &lt;code&gt;float&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Vector2&lt;/code&gt; / &lt;code&gt;Color&lt;/code&gt; round-trip&lt;/td&gt;
&lt;td&gt;manual&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dictionary int keys&lt;/td&gt;
&lt;td&gt;become strings&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typed fields + autocomplete&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readable / editable by players&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes (and riskier)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parseable outside Godot&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing key has a default&lt;/td&gt;
&lt;td&gt;hand-rolled&lt;/td&gt;
&lt;td&gt;built in&lt;/td&gt;
&lt;td&gt;class default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safe to load untrusted files&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Survives renaming your classes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Built-in password encryption&lt;/td&gt;
&lt;td&gt;via &lt;code&gt;FileAccess&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;save_encrypted_pass&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One honest note on that last row: the key ships inside your game binary, so encryption&lt;br&gt;
here stops casual editing, not a determined player.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Shipping a single-player game and you want the least future pain?&lt;/strong&gt; ConfigFile. It&lt;br&gt;
round-trips Godot's types, defaults are built in, and nothing in it can run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anything outside Godot ever reads the save&lt;/strong&gt; — a server, a stats page, a tool? JSON, and&lt;br&gt;
pay the type-casting tax deliberately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prototype or jam, all internal, speed is everything?&lt;/strong&gt; Resource. Just know you'll want&lt;br&gt;
to move off it before you accept a save file you didn't write.&lt;/p&gt;

&lt;p&gt;Whichever you pick, the format is the easy half. The half that shows up later is&lt;br&gt;
versioning old saves into a new schema, not corrupting the file when the game is killed&lt;br&gt;
mid-write, and keeping a backup to fall back on. Saltmire Save is the paid tool I built&lt;br&gt;
after writing that layer one time too many: one-line save and load, plus schema migration,&lt;br&gt;
backup recovery, gzip compression and encryption — MIT licensed, so you can read exactly&lt;br&gt;
how it handles each of these.&lt;/p&gt;




&lt;p&gt;If you'd rather drop this in than build it, &lt;strong&gt;Saltmire Save&lt;/strong&gt; does it as a ready-made tool: &lt;a href="https://saltmire.itch.io/saltmire-save" rel="noopener noreferrer"&gt;https://saltmire.itch.io/saltmire-save&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://saltmire.github.io/godot-4-json-vs-configfile-vs-resource-save.html" rel="noopener noreferrer"&gt;https://saltmire.github.io/godot-4-json-vs-configfile-vs-resource-save.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>Fixing set_shader_parameter not working in Godot 4</title>
      <dc:creator>saltmire</dc:creator>
      <pubDate>Thu, 15 Oct 2026 12:30:01 +0000</pubDate>
      <link>https://dev.to/saltmire/fixing-setshaderparameter-not-working-in-godot-4-j1i</link>
      <guid>https://dev.to/saltmire/fixing-setshaderparameter-not-working-in-godot-4-j1i</guid>
      <description>&lt;p&gt;Sprite shaders are the one place in Godot 4 where wrong code doesn't crash — it just&lt;br&gt;
quietly does nothing. You call a setter, the sprite doesn't change, and there is no error&lt;br&gt;
to search for. Or worse: it works, and lights up all twelve enemies on screen.&lt;/p&gt;

&lt;p&gt;The symptoms that bring people here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Invalid call. Nonexistent function 'set_shader_param' in base 'ShaderMaterial'.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;The uniform changes fine in the inspector, but nothing happens from code.&lt;/li&gt;
&lt;li&gt;One enemy gets hit and &lt;strong&gt;every&lt;/strong&gt; enemy flashes white.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Invalid property path 'shader_param/progress'&lt;/code&gt; from a tween.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five causes, ordered by how often they show up.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. You're calling the Godot 3 name
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; &lt;code&gt;Nonexistent function 'set_shader_param'&lt;/code&gt;, or an old tutorial's&lt;br&gt;
&lt;code&gt;material.set("shader_param/flash", 1.0)&lt;/code&gt; that fails silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; Godot 4 renamed it. &lt;code&gt;shader_param&lt;/code&gt; is gone; it's &lt;code&gt;shader_parameter&lt;/code&gt; everywhere —&lt;br&gt;
the method, the property path, the &lt;code&gt;get_&lt;/code&gt; version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Godot 3 — dead&lt;/span&gt;
&lt;span class="n"&gt;material&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_shader_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"flash_amount"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;material&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"shader_param/flash_amount"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Godot 4&lt;/span&gt;
&lt;span class="n"&gt;material&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_shader_parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"flash_amount"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note which half fails. The method call raises an error you can see. The&lt;br&gt;
&lt;code&gt;material.set("shader_param/...")&lt;/code&gt; form is a generic property write — it returns nothing&lt;br&gt;
and warns nothing, which is why it survives in copy-pasted code for months.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. The material is shared between every instance
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; The shader works, but hitting one enemy flashes the whole screen's worth&lt;br&gt;
of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; A &lt;code&gt;ShaderMaterial&lt;/code&gt; saved as a &lt;code&gt;.tres&lt;/code&gt; file, or assigned once in a scene, is&lt;br&gt;
&lt;strong&gt;one resource&lt;/strong&gt;. Every instance of that scene points at the same object. Setting a&lt;br&gt;
parameter sets it for all of them. This is also why it looks right in a single-enemy&lt;br&gt;
test scene and breaks the moment you spawn a crowd.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Give each instance its own copy on spawn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&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="c1"&gt;# shallow duplicate: own parameter values, same compiled Shader&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;mat&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sprite&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;material&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ShaderMaterial&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sprite&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;material&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or do it without code: select the material in the inspector and tick &lt;strong&gt;Resource &amp;gt;&lt;br&gt;
Local to Scene&lt;/strong&gt;. Each instantiated scene then gets its own copy automatically.&lt;/p&gt;

&lt;p&gt;Worth knowing: the per-instance uniform shortcut&lt;br&gt;
(&lt;code&gt;set_instance_shader_parameter&lt;/code&gt;) lives on &lt;code&gt;GeometryInstance3D&lt;/code&gt;, not on &lt;code&gt;CanvasItem&lt;/code&gt;. In&lt;br&gt;
2D, &lt;code&gt;duplicate()&lt;/code&gt; or Local to Scene is the answer.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. The uniform name doesn't match — and Godot won't tell you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; No error at all. The call runs, nothing changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; &lt;code&gt;set_shader_parameter()&lt;/code&gt; accepts any name. If no uniform matches — a typo, a&lt;br&gt;
renamed uniform, the wrong shader on the node — the value is stored and never read.&lt;br&gt;
There is no warning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Check against the shader's actual uniform list instead of guessing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&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;var&lt;/span&gt; &lt;span class="n"&gt;mat&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sprite&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;material&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ShaderMaterial&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;mat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_shader_uniform_list&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;" : "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two related traps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A uniform you never declared in the shader won't appear in the inspector either — that's
your fastest check. Visible in the inspector, ignored from code means a typo in the
string, not a shader problem.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_shader_parameter()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; when nothing has been set on that material. It
does not fall back to the default written in the shader, so don't use it to read a
starting value.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. A tween is animating the wrong property path
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; &lt;code&gt;Invalid property path&lt;/code&gt;, or a tween that completes instantly with no visible&lt;br&gt;
change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; Tweening a uniform means tweening a property &lt;strong&gt;on the material&lt;/strong&gt;, with the&lt;br&gt;
Godot 4 path, not the node and not the Godot 3 prefix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;flash&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;var&lt;/span&gt; &lt;span class="n"&gt;mat&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sprite&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;material&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ShaderMaterial&lt;/span&gt;
    &lt;span class="n"&gt;mat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_shader_parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"flash_amount"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tw&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;create_tween&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;tw&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tween_property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"shader_parameter/flash_amount"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;create_tween()&lt;/code&gt; on the node binds the tween's life to that node, so a dying enemy can't&lt;br&gt;
leave a tween poking at a freed material.&lt;/p&gt;
&lt;h2&gt;
  
  
  5. &lt;code&gt;use_parent_material&lt;/code&gt; is on
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Setting the parameter on the node's own material does nothing, and the&lt;br&gt;
inspector seems to ignore the material too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; &lt;code&gt;CanvasItem.use_parent_material&lt;/code&gt; is checked (sometimes inherited from a scene&lt;br&gt;
you copied). The node renders with the parent's material; its own is unused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Uncheck it, or set the parameter on whichever material actually renders:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;mat&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sprite&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;material&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ShaderMaterial&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sprite&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;use_parent_material&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sprite&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_parent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;material&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ShaderMaterial&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The quick triage order
&lt;/h2&gt;

&lt;p&gt;When a uniform won't move, go in this order: is the name in&lt;br&gt;
&lt;code&gt;get_shader_uniform_list()&lt;/code&gt;? Is &lt;code&gt;use_parent_material&lt;/code&gt; off? Is the material a&lt;br&gt;
&lt;code&gt;duplicate()&lt;/code&gt; or Local to Scene? Is the tween path&lt;br&gt;
&lt;code&gt;shader_parameter/&amp;lt;name&amp;gt;&lt;/code&gt;? Four checks and the silent cases stop being mysterious.&lt;/p&gt;

&lt;p&gt;Most of this is plumbing, not effects work — which is the thinking behind Saltmire FX.&lt;br&gt;
Each call (&lt;code&gt;FX.hit_flash($Sprite2D)&lt;/code&gt;, &lt;code&gt;FX.dissolve($Sprite2D)&lt;/code&gt;, outline, freeze,&lt;br&gt;
hologram) builds a fresh &lt;code&gt;ShaderMaterial&lt;/code&gt; on the node you passed in, so causes 1, 2 and 3&lt;br&gt;
can't reach your code: nothing is shared between instances and no uniform name is spelled&lt;br&gt;
on your side. The tweens come from &lt;code&gt;target.create_tween()&lt;/code&gt;, for the lifetime reason in&lt;br&gt;
cause 4. It's MIT and the shaders are plain &lt;code&gt;.gdshader&lt;/code&gt; files, so you can read exactly&lt;br&gt;
what it does, or lift the pattern and keep writing it yourself — the five checks above are&lt;br&gt;
the whole of it either way.&lt;/p&gt;




&lt;p&gt;If you'd rather drop this in than build it, &lt;strong&gt;Saltmire FX&lt;/strong&gt; does it as a ready-made tool: &lt;a href="https://saltmire.itch.io/saltmire-fx" rel="noopener noreferrer"&gt;https://saltmire.itch.io/saltmire-fx&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://saltmire.github.io/godot-4-set-shader-parameter-not-working.html" rel="noopener noreferrer"&gt;https://saltmire.github.io/godot-4-set-shader-parameter-not-working.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>7 Godot 4 melee attack mistakes (and the fix for each)</title>
      <dc:creator>saltmire</dc:creator>
      <pubDate>Tue, 13 Oct 2026 12:21:35 +0000</pubDate>
      <link>https://dev.to/saltmire/7-godot-4-melee-attack-mistakes-and-the-fix-for-each-2oe</link>
      <guid>https://dev.to/saltmire/7-godot-4-melee-attack-mistakes-and-the-fix-for-each-2oe</guid>
      <description>&lt;p&gt;A melee attack in Godot 4 looks like a ten-minute job: put an &lt;code&gt;Area2D&lt;/code&gt; on the sword,&lt;br&gt;
listen for &lt;code&gt;area_entered&lt;/code&gt;, subtract health. Then you playtest. The enemy dies from&lt;br&gt;
standing next to you, one swing deals triple damage, the player hits themselves, and the&lt;br&gt;
sword hitbox points right while the character faces left.&lt;/p&gt;

&lt;p&gt;These are the seven mistakes behind almost every "my melee attack is broken" thread,&lt;br&gt;
with the fix for each.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. The hitbox is on all the time
&lt;/h2&gt;

&lt;p&gt;If the sword's &lt;code&gt;Area2D&lt;/code&gt; monitors every frame, anything that touches the sword takes&lt;br&gt;
damage — including an enemy that just walked into you while you were idle. A hitbox&lt;br&gt;
should only exist during the &lt;em&gt;active frames&lt;/em&gt; of the attack.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# player.gd&lt;/span&gt;
&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;onready&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sword_hitbox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Area2D&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="n"&gt;Pivot&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;SwordHitbox&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&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="n"&gt;sword_hitbox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;monitoring&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;false&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;attack&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="o"&gt;$&lt;/span&gt;&lt;span class="n"&gt;AnimationPlayer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;play&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"attack"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, in the &lt;code&gt;attack&lt;/code&gt; animation, add a &lt;strong&gt;Property track&lt;/strong&gt; on&lt;br&gt;
&lt;code&gt;Pivot/SwordHitbox:monitoring&lt;/code&gt;: key &lt;code&gt;true&lt;/code&gt; on the frame the blade swings out, &lt;code&gt;false&lt;/code&gt;&lt;br&gt;
when it's done. The animation now owns the timing, which is where a designer will look&lt;br&gt;
for it.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. One swing hits the same enemy three times
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;area_entered&lt;/code&gt; fires every time an overlap &lt;em&gt;starts&lt;/em&gt;. If the enemy's hurtbox has two&lt;br&gt;
shapes, or the enemy gets knocked back and slides into the blade again, one swing lands&lt;br&gt;
several hits. Keep a list of what this swing already hit and clear it when a new swing&lt;br&gt;
starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# sword_hitbox.gd&lt;/span&gt;
&lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Area2D&lt;/span&gt;

&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;damage&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;_hit_this_swing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Area2D&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&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="n"&gt;area_entered&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_on_area_entered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;start_swing&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="n"&gt;_hit_this_swing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_on_area_entered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Area2D&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;if&lt;/span&gt; &lt;span class="n"&gt;area&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_hit_this_swing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;_hit_this_swing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"take_hit"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;take_hit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;damage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;global_position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call &lt;code&gt;start_swing()&lt;/code&gt; from a &lt;strong&gt;Call Method&lt;/strong&gt; track on the first frame of the attack&lt;br&gt;
animation, right before &lt;code&gt;monitoring&lt;/code&gt; turns on.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. The player hits themselves
&lt;/h2&gt;

&lt;p&gt;The sword's &lt;code&gt;Area2D&lt;/code&gt; overlaps the player's own hurtbox on the first frame of every&lt;br&gt;
swing. Don't fix this with &lt;code&gt;if area.owner == self&lt;/code&gt; checks scattered everywhere — fix it&lt;br&gt;
in the collision bits. Put hurtboxes on team layers (&lt;code&gt;player_hurtbox&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;enemy_hurtbox&lt;/code&gt;) and give the player's sword a mask that only contains&lt;br&gt;
&lt;code&gt;enemy_hurtbox&lt;/code&gt;. The overlap never happens, so there's nothing to filter.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. "Function blocked during in/out signal"
&lt;/h2&gt;

&lt;p&gt;You try to turn the hitbox off the moment it hits something:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_on_area_entered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Area2D&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="n"&gt;monitoring&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;false&lt;/span&gt;  &lt;span class="c1"&gt;# error!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Godot refuses, because you're changing physics state while the physics server is in the&lt;br&gt;
middle of reporting overlaps. The error message tells you the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_on_area_entered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Area2D&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="n"&gt;set_deferred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"monitoring"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same applies to toggling &lt;code&gt;CollisionShape2D.disabled&lt;/code&gt; from inside a body or area&lt;br&gt;
signal — use &lt;code&gt;set_deferred("disabled", true)&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  5. The hitbox doesn't flip with the character
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Sprite2D.flip_h = true&lt;/code&gt; mirrors the &lt;em&gt;picture&lt;/em&gt;, not the child nodes. Your sword hitbox&lt;br&gt;
stays on the right. Put everything that should mirror under a &lt;code&gt;Node2D&lt;/code&gt; pivot and flip&lt;br&gt;
that instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;onready&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;pivot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Node2D&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="n"&gt;Pivot&lt;/span&gt;  &lt;span class="c1"&gt;# holds Sprite2D + SwordHitbox&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_physics_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&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;var&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Input&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_axis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"move_left"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"move_right"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;pivot&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flip the pivot, not the &lt;code&gt;CharacterBody2D&lt;/code&gt; itself — a negative scale on a physics body&lt;br&gt;
gets reset by the physics server and causes strange behaviour.&lt;/p&gt;
&lt;h2&gt;
  
  
  6. The damage logic lives in the sword
&lt;/h2&gt;

&lt;p&gt;When the hitbox does &lt;code&gt;area.get_parent().health -= damage&lt;/code&gt;, every attack in the game&lt;br&gt;
needs to know how every target stores health, handles invincibility frames and reacts.&lt;br&gt;
Flip the dependency: the hurtbox receives the hit and forwards it, and the receiver&lt;br&gt;
decides what happens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# hurtbox.gd&lt;/span&gt;
&lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Area2D&lt;/span&gt;
&lt;span class="k"&gt;signal&lt;/span&gt; &lt;span class="n"&gt;hurt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;from_position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;take_hit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;from_position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Vector2&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="n"&gt;hurt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;from_position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The enemy connects &lt;code&gt;hurt&lt;/code&gt; to its own health component, which can ignore the hit during&lt;br&gt;
i-frames, apply resistances or trigger knockback. The sword stays dumb, and any new&lt;br&gt;
attack — arrows, explosions, spikes — reuses the same hurtbox.&lt;/p&gt;
&lt;h2&gt;
  
  
  7. The active window is one frame long
&lt;/h2&gt;

&lt;p&gt;A fast jab that enables &lt;code&gt;monitoring&lt;/code&gt; for a single frame often misses. The new overlap is&lt;br&gt;
picked up on the next physics step, and if your animation turns the hitbox off before&lt;br&gt;
that step runs, the signal never fires. At 60 physics ticks per second, keep active&lt;br&gt;
windows at &lt;strong&gt;3+ physics frames&lt;/strong&gt; (about 0.05 s). If a design really needs a single-frame&lt;br&gt;
hit, skip the signal and query directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;instant_hit&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="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;get_tree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;physics_frame&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;area&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sword_hitbox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_overlapping_areas&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;_on_area_entered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The pattern behind all seven
&lt;/h2&gt;

&lt;p&gt;Every mistake above is the same thing in a different costume: a melee attack is not&lt;br&gt;
"an Area2D on a sword", it's a small contract — &lt;em&gt;when&lt;/em&gt; the box is live, &lt;em&gt;who&lt;/em&gt; it can&lt;br&gt;
see, &lt;em&gt;how many times&lt;/em&gt; it may hit, and &lt;em&gt;who&lt;/em&gt; decides what a hit means. Write that&lt;br&gt;
contract once and every attack in your game gets it for free.&lt;/p&gt;

&lt;p&gt;Saltmire Hitbox is that contract packaged as a &lt;code&gt;Hitbox2D&lt;/code&gt; / &lt;code&gt;Hurtbox2D&lt;/code&gt; pair for&lt;br&gt;
Godot 4: &lt;code&gt;activate()&lt;/code&gt; / &lt;code&gt;deactivate()&lt;/code&gt; for active windows, a one-hit-per-swing option&lt;br&gt;
with &lt;code&gt;reset()&lt;/code&gt;, teams instead of hand-set collision bits, a helper that binds the&lt;br&gt;
hitbox to specific &lt;code&gt;AnimatedSprite2D&lt;/code&gt; frames, plus knockback and i-frames on the&lt;br&gt;
receiving side. If you'd rather build it yourself, the seven fixes above are the core&lt;br&gt;
of what it does.&lt;/p&gt;




&lt;p&gt;If you'd rather drop this in than build it, &lt;strong&gt;Saltmire Hitbox&lt;/strong&gt; does it as a ready-made tool: &lt;a href="https://saltmire.itch.io/saltmire-hitbox" rel="noopener noreferrer"&gt;https://saltmire.itch.io/saltmire-hitbox&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://saltmire.github.io/godot-4-melee-attack-mistakes.html" rel="noopener noreferrer"&gt;https://saltmire.github.io/godot-4-melee-attack-mistakes.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to make XP gems with a pickup magnet and level-up bar in Godot 4</title>
      <dc:creator>saltmire</dc:creator>
      <pubDate>Sun, 11 Oct 2026 12:26:14 +0000</pubDate>
      <link>https://dev.to/saltmire/how-to-make-xp-gems-with-a-pickup-magnet-and-level-up-bar-in-godot-4-4dm6</link>
      <guid>https://dev.to/saltmire/how-to-make-xp-gems-with-a-pickup-magnet-and-level-up-bar-in-godot-4-4dm6</guid>
      <description>&lt;p&gt;Every survivors-like has the same small loop: an enemy dies, a gem drops, you walk near it,&lt;br&gt;
it flies into you, the XP bar ticks up. It looks like nothing, but a big part of the&lt;br&gt;
satisfaction comes from that loop. Get it wrong and pickups feel sticky or laggy, or they&lt;br&gt;
tank the frame rate once 300 gems are lying on the floor.&lt;/p&gt;

&lt;p&gt;This tutorial builds it in Godot 4: an XP gem scene, a magnet area on the player, a smooth&lt;br&gt;
pull that speeds up, and an &lt;code&gt;XP&lt;/code&gt; component with a level curve that a UI bar can listen to.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: the gem scene
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;xp_gem.tscn&lt;/code&gt; with this tree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;XPGem&lt;/code&gt; (&lt;strong&gt;Area2D&lt;/strong&gt;)

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Sprite2D&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CollisionShape2D&lt;/code&gt; (small circle, radius ~6)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put the gem on its own physics layer, for example layer 5 "pickups", with &lt;strong&gt;no mask&lt;/strong&gt;.&lt;br&gt;
The gem doesn't need to detect anything. The player's areas will detect &lt;em&gt;it&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# xp_gem.gd&lt;/span&gt;
&lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Area2D&lt;/span&gt;

&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Node2D&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;null&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;speed&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;ACCEL&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;900.0&lt;/span&gt;      &lt;span class="c1"&gt;# px/s² — how fast the pull ramps up&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;MAX_SPEED&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;700.0&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;attract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Node2D&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;if&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kt"&gt;null&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_physics_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&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;if&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kt"&gt;null&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_instance_valid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;null&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;speed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ACCEL&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MAX_SPEED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;global_position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;global_position&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;move_toward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;global_position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;speed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The acceleration is the important part. A constant speed looks robotic. Starting at zero&lt;br&gt;
and ramping up gives the little "pop then whoosh" players expect.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2: two areas on the player
&lt;/h2&gt;

&lt;p&gt;The player needs &lt;strong&gt;two&lt;/strong&gt; Area2D children, both with mask = layer 5:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;MagnetArea&lt;/code&gt;: big circle (radius ~80). It decides &lt;em&gt;when&lt;/em&gt; a gem starts flying.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CollectArea&lt;/code&gt;: small circle (radius ~12). It decides &lt;em&gt;when&lt;/em&gt; a gem is absorbed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keeping them separate lets you upgrade the magnet radius without changing how close a gem&lt;br&gt;
has to get before it counts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# player.gd (relevant part)&lt;/span&gt;
&lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;CharacterBody2D&lt;/span&gt;

&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;onready&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;magnet_shape&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CollisionShape2D&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="n"&gt;MagnetArea&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;CollisionShape2D&lt;/span&gt;
&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;onready&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;xp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;XPComponent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="n"&gt;XPComponent&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&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="o"&gt;$&lt;/span&gt;&lt;span class="n"&gt;MagnetArea&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;area_entered&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_on_magnet_area_entered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="n"&gt;CollectArea&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;area_entered&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_on_collect_area_entered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_on_magnet_area_entered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Area2D&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;if&lt;/span&gt; &lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"attract"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_on_collect_area_entered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Area2D&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;if&lt;/span&gt; &lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"attract"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;xp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue_free&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;set_magnet_radius&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;magnet_shape&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;CircleShape2D&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;radius&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha: shapes are shared resources. If several scenes use the same &lt;code&gt;CircleShape2D&lt;/code&gt;,&lt;br&gt;
growing it on one player grows it on all of them. Tick &lt;strong&gt;Local to Scene&lt;/strong&gt; on the shape (or&lt;br&gt;
&lt;code&gt;duplicate()&lt;/code&gt; it in &lt;code&gt;_ready&lt;/code&gt;) before changing the radius at runtime.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: drop gems when enemies die
&lt;/h2&gt;

&lt;p&gt;In your enemy's death code, spawn the gem with &lt;code&gt;call_deferred&lt;/code&gt;. Death usually happens&lt;br&gt;
inside a physics callback, and adding an Area2D during one triggers the&lt;br&gt;
"Can't change this state while flushing queries" error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# enemy.gd&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;GEM&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"res://xp_gem.tscn"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;xp_value&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;die&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;var&lt;/span&gt; &lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GEM&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instantiate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;gem&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;xp_value&lt;/span&gt;
    &lt;span class="n"&gt;gem&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;global_position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;global_position&lt;/span&gt;
    &lt;span class="n"&gt;get_tree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_scene&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_child&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call_deferred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;queue_free&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: the XP component and level curve
&lt;/h2&gt;

&lt;p&gt;Keep the numbers out of the player script. A small component does the math and only&lt;br&gt;
emits signals, so the UI, the upgrade screen and the audio can each react on their own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# xp_component.gd&lt;/span&gt;
&lt;span class="k"&gt;class_name&lt;/span&gt; &lt;span class="n"&gt;XPComponent&lt;/span&gt;
&lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Node&lt;/span&gt;

&lt;span class="k"&gt;signal&lt;/span&gt; &lt;span class="n"&gt;xp_changed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;needed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;signal&lt;/span&gt; &lt;span class="n"&gt;leveled_up&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;base_needed&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;growth&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.25&lt;/span&gt;   &lt;span class="c1"&gt;# each level needs 25% more&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;needed_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lvl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_needed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;growth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lvl&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&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="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
    &lt;span class="c1"&gt;# while, not if: one big gem can be worth several levels&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;needed_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;needed_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;leveled_up&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;xp_changed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;needed_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;while&lt;/code&gt; is the most common bug in hand-rolled XP code. With &lt;code&gt;if&lt;/code&gt;, a gem worth 40 XP at&lt;br&gt;
level 1 gives one level and leaves the bar over 100%.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 5: the XP bar
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;ProgressBar&lt;/code&gt; (or &lt;code&gt;TextureProgressBar&lt;/code&gt;) at the top of the screen, driven only by the&lt;br&gt;
signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# xp_bar.gd&lt;/span&gt;
&lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;ProgressBar&lt;/span&gt;

&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;xp_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;NodePath&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&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;var&lt;/span&gt; &lt;span class="n"&gt;xp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;XPComponent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xp_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;xp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xp_changed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_on_xp_changed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_on_xp_changed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;needed_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_on_xp_changed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;needed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&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="n"&gt;max_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;needed&lt;/span&gt;
    &lt;span class="n"&gt;create_tween&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tween_property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The short tween makes the bar slide instead of snap. On level-up it drops back toward zero&lt;br&gt;
and refills, so the player can actually see it happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: survive 300 gems on screen
&lt;/h2&gt;

&lt;p&gt;Late in a run, gems pile up faster than the player collects them. Three cheap fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Merge on the floor.&lt;/strong&gt; If more than ~150 gems exist, spawn the new one's value into the
nearest existing gem instead of creating another node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't run idle gems.&lt;/strong&gt; Call &lt;code&gt;set_physics_process(false)&lt;/code&gt; in the gem's &lt;code&gt;_ready()&lt;/code&gt; and
turn it back on in &lt;code&gt;attract()&lt;/code&gt;. Then only gems that are flying cost anything per frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vacuum pickup.&lt;/strong&gt; A rare drop that calls &lt;code&gt;attract(player)&lt;/code&gt; on every node in a &lt;code&gt;"gems"&lt;/code&gt;
group clears the floor and feels great.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;That's the whole loop: gem drops, magnet pull with acceleration, collect, XP curve with a&lt;br&gt;
&lt;code&gt;while&lt;/code&gt;, bar that tweens. What usually comes next is the level-up draft (pick 1 of 3&lt;br&gt;
upgrades), weapons that scale with it, and waves that ramp up. That's where a small&lt;br&gt;
prototype turns into a lot of systems to keep in sync. If you'd rather start from a&lt;br&gt;
version where all of that is already wired together and data-driven, the Saltmire Survivors&lt;br&gt;
Template ships it as a playable Godot 4 project.&lt;/p&gt;




&lt;p&gt;If you'd rather drop this in than build it, &lt;strong&gt;Survivors Template&lt;/strong&gt; does it as a ready-made tool: &lt;a href="https://saltmire.itch.io/survivors-template-godot" rel="noopener noreferrer"&gt;https://saltmire.itch.io/survivors-template-godot&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://saltmire.github.io/godot-4-xp-gem-magnet-pickup.html" rel="noopener noreferrer"&gt;https://saltmire.github.io/godot-4-xp-gem-magnet-pickup.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>What I learned making a smooth 2D camera follow in Godot 4</title>
      <dc:creator>saltmire</dc:creator>
      <pubDate>Fri, 09 Oct 2026 12:27:27 +0000</pubDate>
      <link>https://dev.to/saltmire/what-i-learned-making-a-smooth-2d-camera-follow-in-godot-4-ddl</link>
      <guid>https://dev.to/saltmire/what-i-learned-making-a-smooth-2d-camera-follow-in-godot-4-ddl</guid>
      <description>&lt;p&gt;A 2D camera looks like a five-minute job. Drop a &lt;code&gt;Camera2D&lt;/code&gt; under the player, tick a box,&lt;br&gt;
done. Then the sprite starts to shimmer, the camera shows the grey void past the edge of&lt;br&gt;
the level, and every respawn swooshes across the whole map. Here is what I learned making&lt;br&gt;
the camera feel right, in the order the problems showed up.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lesson 1: a child camera is fine — until it isn't
&lt;/h2&gt;

&lt;p&gt;The fastest setup is a &lt;code&gt;Camera2D&lt;/code&gt; as a child of the player. It follows perfectly because&lt;br&gt;
it &lt;em&gt;is&lt;/em&gt; the player's transform. That's also the problem: when the player is freed on&lt;br&gt;
death, the camera goes with it and the view snaps to the origin for a frame.&lt;/p&gt;

&lt;p&gt;What I use now is a camera that lives in the level and chases a target:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Node2D&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_physics_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&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;if&lt;/span&gt; &lt;span class="n"&gt;is_instance_valid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;global_position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;global_position&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The camera survives the player, can be pointed at a boss or a cutscene marker, and the&lt;br&gt;
&lt;code&gt;is_instance_valid&lt;/code&gt; check means a freed target just leaves the camera where it was.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lesson 2: smoothing jitter is a timing problem, not a speed problem
&lt;/h2&gt;

&lt;p&gt;I turned on &lt;code&gt;position_smoothing_enabled&lt;/code&gt;, and the player sprite started to vibrate while&lt;br&gt;
running. I spent too long tweaking &lt;code&gt;position_smoothing_speed&lt;/code&gt;. No value fixed it.&lt;/p&gt;

&lt;p&gt;The cause: the player moves in &lt;code&gt;_physics_process&lt;/code&gt; (60 Hz by default) and the camera was&lt;br&gt;
updating at the render rate. On a 144 Hz monitor they disagree on most frames, and&lt;br&gt;
smoothing makes the mismatch visible. Make both run on the same clock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&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="n"&gt;process_callback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Camera2D&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CAMERA2D_PROCESS_PHYSICS&lt;/span&gt;
    &lt;span class="n"&gt;position_smoothing_enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;
    &lt;span class="n"&gt;position_smoothing_speed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;6.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fixed 90% of it. On Godot 4.3+, turning on &lt;strong&gt;Project Settings → Physics → Common →&lt;br&gt;
Physics Interpolation&lt;/strong&gt; fixed the rest, because it renders the in-between positions&lt;br&gt;
for both the player and the camera. If you use pixel art, also check&lt;br&gt;
&lt;code&gt;rendering/2d/snap/snap_2d_transforms_to_pixel&lt;/code&gt; — sub-pixel camera positions make&lt;br&gt;
pixel sprites shimmer even when the timing is right.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lesson 3: read the limits from the level instead of typing them in
&lt;/h2&gt;

&lt;p&gt;I hand-typed &lt;code&gt;limit_right = 3200&lt;/code&gt; for the first level. The second level was a different&lt;br&gt;
size, and I forgot. The tilemap already knows how big the level is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;fit_to_tilemap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TileMapLayer&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;var&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_used_rect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cell&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tile_set&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tile_size&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;top_left&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to_global&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;bottom_right&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to_global&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;limit_left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;top_left&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;limit_top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;top_left&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;limit_right&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bottom_right&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;limit_bottom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bottom_right&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details I got wrong first. Limits are in &lt;strong&gt;global&lt;/strong&gt; coordinates, so a tilemap that&lt;br&gt;
isn't at the origin needs the &lt;code&gt;to_global&lt;/code&gt; call. And if the level is smaller than the&lt;br&gt;
screen, Godot can't satisfy both limits at once, so one side wins and the level sits&lt;br&gt;
off-centre. For small rooms, I skip the limits and centre the camera on the room.&lt;/p&gt;

&lt;p&gt;Turn on &lt;code&gt;limit_smoothed = true&lt;/code&gt; too. Without it, the camera slides smoothly everywhere&lt;br&gt;
except at the edge, where it suddenly stops dead.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lesson 4: teleports need &lt;code&gt;reset_smoothing()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;With smoothing on, every respawn, door and checkpoint made the camera glide across the&lt;br&gt;
whole level to catch up. It looks like a bug because it is one. After moving the target&lt;br&gt;
instantly, tell the camera to jump too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;snap_to_target&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="n"&gt;global_position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;global_position&lt;/span&gt;
    &lt;span class="n"&gt;reset_smoothing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I call this from the respawn and from scene load. Calling it from &lt;code&gt;_ready()&lt;/code&gt; alone&lt;br&gt;
wasn't enough, because the target sometimes got its spawn position a frame later, so&lt;br&gt;
I added a &lt;code&gt;call_deferred("snap_to_target")&lt;/code&gt; there.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lesson 5: look-ahead and screen shake both want &lt;code&gt;offset&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Platformers and top-down shooters feel better when the camera leans in the direction&lt;br&gt;
you're moving, so you can see what's coming. The clean way is &lt;code&gt;offset&lt;/code&gt;, because it&lt;br&gt;
doesn't fight the smoothing on &lt;code&gt;position&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;look_ahead&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;48.0&lt;/span&gt;
&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;look_speed&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;4.0&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_physics_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&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;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_instance_valid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;global_position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;global_position&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ZERO&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;vel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"velocity"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# null if the target has no velocity&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;vel&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;lerp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;look_ahead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;look_speed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I added screen shake, which &lt;em&gt;also&lt;/em&gt; writes to &lt;code&gt;offset&lt;/code&gt;. The two overwrote each&lt;br&gt;
other every frame. Shake vanished while running, and stopping mid-shake left the camera&lt;br&gt;
crooked. The fix is to keep each effect in its own variable and add them together in one&lt;br&gt;
place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;_look&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ZERO&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;_shake&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ZERO&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_physics_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&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="c1"&gt;# ...update _look as above, update _shake from your shake code...&lt;/span&gt;
    &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_look&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;_shake&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line keeps both effects working.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently starting over
&lt;/h2&gt;

&lt;p&gt;Start with a free-standing camera that follows a target, set to the physics clock. Read&lt;br&gt;
limits from the level, and write &lt;code&gt;snap_to_target()&lt;/code&gt; before the first respawn exists.&lt;br&gt;
Treat &lt;code&gt;offset&lt;/code&gt; as shared: anything that moves the view adds its own term instead of&lt;br&gt;
writing directly.&lt;/p&gt;

&lt;p&gt;That last point is where most of my camera bugs came from. Shake, hit-stop and&lt;br&gt;
look-ahead all touch the same few properties at the same moment, and mixing them by&lt;br&gt;
hand gets fiddly fast. That combination is what I eventually packaged up, so I stopped&lt;br&gt;
rewriting it every project.&lt;/p&gt;




&lt;p&gt;If you'd rather drop this in than build it, &lt;strong&gt;Saltmire Impact&lt;/strong&gt; does it as a ready-made tool: &lt;a href="https://saltmire.itch.io/saltmire-impact" rel="noopener noreferrer"&gt;https://saltmire.itch.io/saltmire-impact&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://saltmire.github.io/godot-4-camera2d-follow-smoothing-limits.html" rel="noopener noreferrer"&gt;https://saltmire.github.io/godot-4-camera2d-follow-smoothing-limits.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>GPUParticles2D vs CPUParticles2D in Godot 4 — which and when</title>
      <dc:creator>saltmire</dc:creator>
      <pubDate>Wed, 07 Oct 2026 12:31:13 +0000</pubDate>
      <link>https://dev.to/saltmire/gpuparticles2d-vs-cpuparticles2d-in-godot-4-which-and-when-3e1f</link>
      <guid>https://dev.to/saltmire/gpuparticles2d-vs-cpuparticles2d-in-godot-4-which-and-when-3e1f</guid>
      <description>&lt;p&gt;You open the Add Node dialog, type "particles", and Godot 4 offers you two nodes that&lt;br&gt;
look identical in the viewport: &lt;code&gt;GPUParticles2D&lt;/code&gt; and &lt;code&gt;CPUParticles2D&lt;/code&gt;. Both emit&lt;br&gt;
sprites, both have &lt;code&gt;amount&lt;/code&gt;, &lt;code&gt;lifetime&lt;/code&gt;, &lt;code&gt;one_shot&lt;/code&gt; and &lt;code&gt;explosiveness&lt;/code&gt;. The names tell&lt;br&gt;
you &lt;em&gt;where&lt;/em&gt; the simulation runs, but not what that means for your game. Here's the&lt;br&gt;
practical difference, and the rule I use to pick.&lt;/p&gt;
&lt;h2&gt;
  
  
  GPUParticles2D: the simulation lives in a shader
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GPUParticles2D&lt;/code&gt; moves every particle on the graphics card. Its behaviour comes from a&lt;br&gt;
&lt;code&gt;ParticleProcessMaterial&lt;/code&gt; — velocity, gravity, scale curves, color ramps — which Godot&lt;br&gt;
compiles into a shader.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GPUParticles2D&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;mat&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ParticleProcessMaterial&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;mat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Vector3&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;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&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;span class="n"&gt;mat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spread&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;45.0&lt;/span&gt;
&lt;span class="n"&gt;mat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initial_velocity_min&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;120.0&lt;/span&gt;
&lt;span class="n"&gt;mat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initial_velocity_max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;220.0&lt;/span&gt;
&lt;span class="n"&gt;mat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gravity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Vector3&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;span class="mi"&gt;400&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;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;process_material&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mat&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lifetime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;one_shot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;explosiveness&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
&lt;span class="n"&gt;add_child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it gives you that the CPU node doesn't: &lt;strong&gt;turbulence&lt;/strong&gt;, &lt;strong&gt;collision&lt;/strong&gt; against&lt;br&gt;
&lt;code&gt;LightOccluder2D&lt;/code&gt; shapes, &lt;strong&gt;sub-emitters&lt;/strong&gt; (a spark that spawns smoke when it dies),&lt;br&gt;
&lt;strong&gt;trails&lt;/strong&gt;, and custom particle shaders. It also scales: thousands of particles cost&lt;br&gt;
the CPU almost nothing, because the CPU never touches them.&lt;/p&gt;

&lt;p&gt;What it costs you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;visibility_rect&lt;/code&gt; you must maintain.&lt;/strong&gt; The node is culled by that rectangle, and
the default is small. Particles that fly outside it simply vanish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A first-use hitch.&lt;/strong&gt; The process material is a shader, and shaders compile the first
time they're used. On many machines the very first explosion stutters for a frame. You
can hide it by firing each effect once off-screen during your loading screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Renderer quirks.&lt;/strong&gt; GPU particles only work on the Compatibility renderer (the one
web exports and many low-end Android devices use) since Godot 4.2, and that path is
less battle-tested than Forward+. If you target the web, test there early.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  CPUParticles2D: the simulation lives in a script loop
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CPUParticles2D&lt;/code&gt; does the same job in engine code on the CPU. There is no process&lt;br&gt;
material — every setting is a property directly on the node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CPUParticles2D&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Vector2&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;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spread&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;45.0&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initial_velocity_min&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;120.0&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initial_velocity_max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;220.0&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gravity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Vector2&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;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lifetime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;one_shot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;explosiveness&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
&lt;span class="n"&gt;add_child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole point of it: &lt;strong&gt;it behaves the same everywhere&lt;/strong&gt;. Forward+, Mobile,&lt;br&gt;
Compatibility, web, old integrated GPUs — the simulation is identical because it never&lt;br&gt;
depended on the graphics card. There's no shader to compile, so no first-burst hitch,&lt;br&gt;
and no &lt;code&gt;visibility_rect&lt;/code&gt; to babysit — the bounds are computed for you.&lt;/p&gt;

&lt;p&gt;The trade-off is a smaller feature set (no turbulence, collision, sub-emitters or trails)&lt;br&gt;
and a cost that grows with particle count. Every particle is updated on the main CPU&lt;br&gt;
every frame. Sixteen sparks per hit is nothing. Ten thousand snowflakes is not.&lt;/p&gt;
&lt;h2&gt;
  
  
  The comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;GPUParticles2D&lt;/th&gt;
&lt;th&gt;CPUParticles2D&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where it simulates&lt;/td&gt;
&lt;td&gt;GPU (shader)&lt;/td&gt;
&lt;td&gt;CPU (engine loop)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configuration&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ParticleProcessMaterial&lt;/code&gt; resource&lt;/td&gt;
&lt;td&gt;properties on the node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost of 10,000 particles&lt;/td&gt;
&lt;td&gt;low on CPU&lt;/td&gt;
&lt;td&gt;high on CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost of 15 small bursts&lt;/td&gt;
&lt;td&gt;a draw + a shader each&lt;/td&gt;
&lt;td&gt;tiny&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turbulence, collision, sub-emitters, trails&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First-use shader hitch&lt;/td&gt;
&lt;td&gt;yes (pre-warm it)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;visibility_rect&lt;/code&gt; culling&lt;/td&gt;
&lt;td&gt;manual, easy to get wrong&lt;/td&gt;
&lt;td&gt;automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compatibility renderer / web&lt;/td&gt;
&lt;td&gt;supported since 4.2, test it&lt;/td&gt;
&lt;td&gt;works everywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;restart()&lt;/code&gt;, &lt;code&gt;one_shot&lt;/code&gt;, &lt;code&gt;finished&lt;/code&gt; signal&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Switching costs one click
&lt;/h2&gt;

&lt;p&gt;You don't have to commit forever. Select a &lt;code&gt;GPUParticles2D&lt;/code&gt; in the editor and the&lt;br&gt;
toolbar's &lt;strong&gt;GPUParticles2D&lt;/strong&gt; menu has &lt;strong&gt;Convert to CPUParticles2D&lt;/strong&gt;. The CPU node has the&lt;br&gt;
reverse option in its own toolbar menu. Features the CPU node lacks are dropped in the&lt;br&gt;
conversion, so commit the scene first and compare.&lt;/p&gt;

&lt;p&gt;If you build effects in code and ship to several platforms, you can also pick at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;make_burst_node&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;Node2D&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ProjectSettings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"rendering/renderer/rendering_method"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"gl_compatibility"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;OS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_feature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"web"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;CPUParticles2D&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;GPUParticles2D&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you go this route, keep your tuning values in one place (a dictionary or a custom&lt;br&gt;
resource) so both branches read the same numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that decides it
&lt;/h2&gt;

&lt;p&gt;Ask two questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is it a big, long-lived or fancy effect?&lt;/strong&gt; Rain, snow, a fog bank, a magic vortex
with turbulence, sparks that bounce off walls. That's &lt;code&gt;GPUParticles2D&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it a small, short burst that fires a lot?&lt;/strong&gt; Hit sparks, pickup glitter, dust from
a footstep, a coin pop. That's &lt;code&gt;CPUParticles2D&lt;/code&gt;. Especially if you export to the web.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most 2D action games end up with both: a few big GPU systems for ambience, and many tiny&lt;br&gt;
CPU bursts for feedback. The bursts are where the work really piles up, though — every&lt;br&gt;
new enemy hit, pickup and death wants its own tuned node, its own &lt;code&gt;one_shot&lt;/code&gt; setup, and&lt;br&gt;
its own cleanup when it's done.&lt;/p&gt;

&lt;p&gt;That's the part Saltmire Spark takes off your plate. It's a free, MIT-licensed autoload&lt;br&gt;
that fires a tuned 2D burst in one line (&lt;code&gt;Spark.burst(pos, "hit")&lt;/code&gt;) and frees itself&lt;br&gt;
afterwards. It doesn't use either particle node — the particles are drawn procedurally —&lt;br&gt;
so there's no process material, no &lt;code&gt;visibility_rect&lt;/code&gt; and no renderer difference to think&lt;br&gt;
about.&lt;/p&gt;




&lt;p&gt;If you'd rather drop this in than build it, &lt;strong&gt;Saltmire Spark&lt;/strong&gt; does it as a ready-made tool: &lt;a href="https://saltmire.itch.io/saltmire-spark" rel="noopener noreferrer"&gt;https://saltmire.itch.io/saltmire-spark&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://saltmire.github.io/godot-4-gpuparticles2d-vs-cpuparticles2d.html" rel="noopener noreferrer"&gt;https://saltmire.github.io/godot-4-gpuparticles2d-vs-cpuparticles2d.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>Fixing 'previously freed instance' errors in Godot 4 combat code</title>
      <dc:creator>saltmire</dc:creator>
      <pubDate>Mon, 05 Oct 2026 12:27:59 +0000</pubDate>
      <link>https://dev.to/saltmire/fixing-previously-freed-instance-errors-in-godot-4-combat-code-5fm6</link>
      <guid>https://dev.to/saltmire/fixing-previously-freed-instance-errors-in-godot-4-combat-code-5fm6</guid>
      <description>&lt;p&gt;Combat is where Godot 4 projects start crashing for the first time. Everything works&lt;br&gt;
until two things die in the same frame, or a homing bullet outlives its target, and the&lt;br&gt;
debugger stops on one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Trying to call a function on a previously freed instance.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Invalid access to property or key 'global_position' on a base object of type 'previously freed'.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Cannot call method 'take_damage' on a null value.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Resumed function 'flash()' after await, but class instance is gone.&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(Exact wording shifts a little between 4.x versions — older builds say&lt;br&gt;
&lt;code&gt;Invalid get index ... (on base: 'previously freed')&lt;/code&gt; — but the causes are the same.)&lt;/p&gt;

&lt;p&gt;"Previously freed" means your variable still points at a node that &lt;code&gt;queue_free()&lt;/code&gt; already&lt;br&gt;
deleted. The reference isn't &lt;code&gt;null&lt;/code&gt; — it's a dangling pointer, so &lt;code&gt;if target:&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;if target != null:&lt;/code&gt; can both let it through. Here are the four ways combat code ends up&lt;br&gt;
holding one, ordered by how often they show up.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. A stored reference outlives the enemy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; A homing projectile, a turret or an enemy AI crashes a few frames after&lt;br&gt;
its target dies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; You saved the target in a variable (&lt;code&gt;var target: Node2D&lt;/code&gt;) and kept using it.&lt;br&gt;
Something else killed the target and called &lt;code&gt;queue_free()&lt;/code&gt;. Your variable never heard&lt;br&gt;
about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Check &lt;code&gt;is_instance_valid()&lt;/code&gt; before every use — it's the only check that catches&lt;br&gt;
a freed node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Node2D&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_physics_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&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;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_instance_valid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;null&lt;/span&gt;
        &lt;span class="n"&gt;_pick_new_target&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;global_position&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;direction_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;global_position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;velocity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;speed&lt;/span&gt;
    &lt;span class="n"&gt;move_and_slide&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If many systems track the same enemy, clear the reference at the source instead of&lt;br&gt;
checking everywhere. &lt;code&gt;tree_exiting&lt;/code&gt; fires right before the node leaves the tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;set_target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Node2D&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="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tree_exiting&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CONNECT_ONE_SHOT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. The reference is used again after an &lt;code&gt;await&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Hit-flash or knockback code crashes only sometimes, usually on the killing&lt;br&gt;
blow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; &lt;code&gt;await&lt;/code&gt; pauses your function. Anything can happen while it's paused —&lt;br&gt;
including the enemy dying. When the function resumes, every node reference captured&lt;br&gt;
before the &lt;code&gt;await&lt;/code&gt; is suspect.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# crashes when the hit also kills the enemy&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;hit_flash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enemy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Node2D&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="n"&gt;enemy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modulate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RED&lt;/span&gt;
    &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;get_tree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_timer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;
    &lt;span class="n"&gt;enemy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modulate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WHITE&lt;/span&gt;   &lt;span class="c1"&gt;# enemy may be gone by now&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Treat every &lt;code&gt;await&lt;/code&gt; as a checkpoint and re-validate after it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;hit_flash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enemy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Node2D&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="n"&gt;enemy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modulate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RED&lt;/span&gt;
    &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;get_tree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_timer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_instance_valid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enemy&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;enemy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modulate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WHITE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even better: put the flash on the enemy itself, as a tween on its own sprite. A tween&lt;br&gt;
created with the node's &lt;code&gt;create_tween()&lt;/code&gt; is bound to that node and dies with it, so there&lt;br&gt;
is nothing left to resume.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Two hits land in the same physics frame — the enemy dies twice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; No crash, but a shotgun blast drops double XP, plays the death sound twice,&lt;br&gt;
or increments the kill counter by two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; &lt;code&gt;queue_free()&lt;/code&gt; doesn't delete immediately — it deletes at the end of the frame.&lt;br&gt;
If two bullets overlap the enemy in the same frame, both callbacks run while the node is&lt;br&gt;
still perfectly valid, and both see &lt;code&gt;hp &amp;lt;= 0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Guard the death path with a flag (or &lt;code&gt;is_queued_for_deletion()&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;dead&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;false&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;take_damage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&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;if&lt;/span&gt; &lt;span class="n"&gt;dead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;hp&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hp&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;dead&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;
        &lt;span class="n"&gt;died&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;drop_xp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;queue_free&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bullets that hit should also stop themselves right away — &lt;code&gt;set_deferred("monitoring", false)&lt;/code&gt;&lt;br&gt;
then &lt;code&gt;queue_free()&lt;/code&gt; — so a single bullet can't register on two enemies in its last frame.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. &lt;code&gt;get_parent()&lt;/code&gt; chains return something you didn't expect
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; &lt;code&gt;Cannot call method 'take_damage' on a null value&lt;/code&gt;, or&lt;br&gt;
&lt;code&gt;Nonexistent function 'take_damage' in base 'Area2D'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; The attacker reaches into the victim:&lt;br&gt;
&lt;code&gt;area.get_parent().take_damage(10)&lt;/code&gt;. That works until one enemy has its hurtbox nested&lt;br&gt;
one level deeper, or the parent is a pooled node that's already been detached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Don't make the attacker know the victim's tree. Check the contract instead of&lt;br&gt;
the shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_on_area_entered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Area2D&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;var&lt;/span&gt; &lt;span class="n"&gt;victim&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_instance_valid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;victim&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;victim&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"take_damage"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;victim&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;take_damage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;damage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The pattern that removes most of these
&lt;/h2&gt;

&lt;p&gt;Notice what causes 1, 2 and 4 have in common: the &lt;strong&gt;attacker&lt;/strong&gt; holds a reference to the&lt;br&gt;
&lt;strong&gt;victim&lt;/strong&gt; and calls into it. Flip that around and most of the bugs disappear. The&lt;br&gt;
attacker only overlaps an area; the victim's own hurtbox emits a signal; the victim&lt;br&gt;
handles its own damage, death and flash. When the victim is freed, its signal&lt;br&gt;
connections go with it — nobody is left holding a stale pointer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# on the enemy — it owns its own death&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&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="o"&gt;$&lt;/span&gt;&lt;span class="n"&gt;Hurtbox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hurt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_on_hurt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_on_hurt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;knockback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Vector2&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="n"&gt;take_damage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;velocity&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;knockback&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's exactly the shape Saltmire Hitbox is built around: drop a &lt;code&gt;Hitbox2D&lt;/code&gt; on the attack&lt;br&gt;
and a &lt;code&gt;Hurtbox2D&lt;/code&gt; on anything that can be hurt, and damage arrives as a &lt;code&gt;hurt&lt;/code&gt; signal on&lt;br&gt;
the victim's side. The hitbox never calls into your enemy's script, &lt;code&gt;one_shot&lt;/code&gt; stops a&lt;br&gt;
lingering swing from hitting the same target every frame, and i-frames are one inspector&lt;br&gt;
field. You still want the &lt;code&gt;dead&lt;/code&gt; flag from cause 3 in your own death code — but the&lt;br&gt;
dangling-reference class of crashes mostly stops existing.&lt;/p&gt;




&lt;p&gt;If you'd rather drop this in than build it, &lt;strong&gt;Saltmire Hitbox&lt;/strong&gt; does it as a ready-made tool: &lt;a href="https://saltmire.itch.io/saltmire-hitbox" rel="noopener noreferrer"&gt;https://saltmire.itch.io/saltmire-hitbox&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://saltmire.github.io/godot-4-previously-freed-instance-error.html" rel="noopener noreferrer"&gt;https://saltmire.github.io/godot-4-previously-freed-instance-error.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>7 Godot 4 collision layer and mask tips that save you an afternoon of debugging</title>
      <dc:creator>saltmire</dc:creator>
      <pubDate>Sat, 03 Oct 2026 12:29:00 +0000</pubDate>
      <link>https://dev.to/saltmire/7-godot-4-collision-layer-and-mask-tips-that-save-you-an-afternoon-of-debugging-16m9</link>
      <guid>https://dev.to/saltmire/7-godot-4-collision-layer-and-mask-tips-that-save-you-an-afternoon-of-debugging-16m9</guid>
      <description>&lt;p&gt;Collision layers and masks are the Godot 4 feature everybody "gets" in five minutes and&lt;br&gt;
then fights for the rest of the project. The player walks through a wall, the enemy's&lt;br&gt;
sword hits other enemies, a raycast sees nothing. Nine times out of ten the physics is&lt;br&gt;
fine — the bits are wrong.&lt;/p&gt;

&lt;p&gt;Here are seven tips that make the bits boring.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Layer is what you are, mask is what you look for
&lt;/h2&gt;

&lt;p&gt;That is the whole rule. &lt;code&gt;collision_layer&lt;/code&gt; says &lt;em&gt;which groups this object belongs to&lt;/em&gt;.&lt;br&gt;
&lt;code&gt;collision_mask&lt;/code&gt; says &lt;em&gt;which groups this object scans for&lt;/em&gt;. Write it down for your game&lt;br&gt;
before you click a single checkbox:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Node&lt;/th&gt;
&lt;th&gt;Layer (what it is)&lt;/th&gt;
&lt;th&gt;Mask (what it looks for)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Player body&lt;/td&gt;
&lt;td&gt;player&lt;/td&gt;
&lt;td&gt;world, enemy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enemy body&lt;/td&gt;
&lt;td&gt;enemy&lt;/td&gt;
&lt;td&gt;world, player&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Player sword (Area2D)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;enemy_hurtbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enemy hurtbox (Area2D)&lt;/td&gt;
&lt;td&gt;enemy_hurtbox&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Walls / floor&lt;/td&gt;
&lt;td&gt;world&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice the empty cells. Static walls don't need to look for anything — things look for&lt;br&gt;
&lt;em&gt;them&lt;/em&gt;. A hurtbox just needs to exist on a layer. Most layer bugs come from filling in&lt;br&gt;
every cell "just in case".&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Name your layers before you use them
&lt;/h2&gt;

&lt;p&gt;Open &lt;strong&gt;Project Settings → General → Layer Names → 2D Physics&lt;/strong&gt; and give layers 1–5 real&lt;br&gt;
names: &lt;code&gt;world&lt;/code&gt;, &lt;code&gt;player&lt;/code&gt;, &lt;code&gt;enemy&lt;/code&gt;, &lt;code&gt;player_hurtbox&lt;/code&gt;, &lt;code&gt;enemy_hurtbox&lt;/code&gt;. The inspector's&lt;br&gt;
layer grid then shows those names on hover, and "why is the sword on layer 4" becomes&lt;br&gt;
a question you can answer by reading.&lt;/p&gt;

&lt;p&gt;You can read the names back in code, which is handy for a debug overlay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;layer_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ProjectSettings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"layer_names/2d_physics/layer_&lt;/span&gt;&lt;span class="si"&gt;%d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. The inspector counts from 1, the raw property is a bitmask
&lt;/h2&gt;

&lt;p&gt;This is the trap that costs the afternoon. In the inspector, layer 3 is the third box.&lt;br&gt;
In code, &lt;code&gt;collision_layer&lt;/code&gt; is a 32-bit &lt;strong&gt;bitmask&lt;/strong&gt; — so this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="n"&gt;collision_layer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="c1"&gt;# looks like "layer 3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;actually means &lt;em&gt;layers 1 and 2&lt;/em&gt; (binary &lt;code&gt;011&lt;/code&gt;). Layer 3 alone is &lt;code&gt;4&lt;/code&gt;. Stop doing the&lt;br&gt;
arithmetic in your head and use the 1-based helpers, which match the inspector:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="n"&gt;collision_layer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;set_collision_layer_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# only layer 3&lt;/span&gt;
&lt;span class="n"&gt;set_collision_mask_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# also scan layer 1&lt;/span&gt;
&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_collision_mask_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When something is off, print the mask in binary and read it right-to-left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;" layer="&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;num_int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;collision_layer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s2"&gt;" mask="&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;num_int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;collision_mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Keep the numbers in one script
&lt;/h2&gt;

&lt;p&gt;Once you set layers from code (spawned projectiles, a hitbox that changes team), magic&lt;br&gt;
numbers spread fast. Put them in one place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# res://physics_layers.gd&lt;/span&gt;
&lt;span class="k"&gt;class_name&lt;/span&gt; &lt;span class="n"&gt;PhysicsLayers&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;WORLD&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;PLAYER&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;ENEMY&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;PLAYER_HURTBOX&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;ENEMY_HURTBOX&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# a bullet fired by the player&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&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="n"&gt;collision_layer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;collision_mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;set_collision_mask_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PhysicsLayers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WORLD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;set_collision_mask_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PhysicsLayers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ENEMY_HURTBOX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep these numbers in sync with the names from tip 2 and you never have to open the&lt;br&gt;
project settings to remember what &lt;code&gt;5&lt;/code&gt; meant.&lt;/p&gt;
&lt;h2&gt;
  
  
  5. Area2D detection is one-way — think from the detector's side
&lt;/h2&gt;

&lt;p&gt;For an &lt;code&gt;Area2D&lt;/code&gt; to report something in &lt;code&gt;area_entered&lt;/code&gt; or &lt;code&gt;body_entered&lt;/code&gt;, &lt;strong&gt;the Area2D's&lt;br&gt;
mask must include the other object's layer&lt;/strong&gt;. The other side's mask is irrelevant for&lt;br&gt;
that signal. So for a sword hitting a hurtbox:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;sword&lt;/strong&gt; (the one listening) masks &lt;code&gt;enemy_hurtbox&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;hurtbox&lt;/strong&gt; sits on layer &lt;code&gt;enemy_hurtbox&lt;/code&gt; and keeps &lt;code&gt;monitorable = true&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Listening from the hurtbox side instead works too — just mirror it. Pick one side.&lt;br&gt;
Masking both ways &lt;em&gt;and&lt;/em&gt; applying damage in both signal handlers is how one slash ends&lt;br&gt;
up dealing damage twice.&lt;/p&gt;

&lt;p&gt;Moving bodies follow the same idea: &lt;code&gt;move_and_slide()&lt;/code&gt; only stops against objects whose&lt;br&gt;
layer is in the &lt;strong&gt;moving body's&lt;/strong&gt; mask. If the player walks through a wall, check the&lt;br&gt;
player's mask for &lt;code&gt;world&lt;/code&gt;, not the wall.&lt;/p&gt;
&lt;h2&gt;
  
  
  6. Raycasts and ShapeCasts have their own mask (and skip areas)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;RayCast2D&lt;/code&gt; and &lt;code&gt;ShapeCast2D&lt;/code&gt; don't care what layer their parent is on. They have their&lt;br&gt;
own &lt;code&gt;collision_mask&lt;/code&gt;, and by default &lt;code&gt;collide_with_areas&lt;/code&gt; is &lt;strong&gt;false&lt;/strong&gt; — so a ray aimed&lt;br&gt;
at a hurtbox sees nothing. The same goes for direct space queries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;line_of_sight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PhysicsRayQueryParameters2D&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collision_mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PhysicsLayers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WORLD&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# bitmask, see tip 3&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exclude&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;get_rid&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_world_2d&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;direct_space_state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;intersect_ray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;1 &amp;lt;&amp;lt; (n - 1)&lt;/code&gt;: the query wants the raw bitmask, not the 1-based number.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. TileMap walls get their layer from the TileSet
&lt;/h2&gt;

&lt;p&gt;Setting the layer on a &lt;code&gt;TileMapLayer&lt;/code&gt; node's inspector won't help if the walls still&lt;br&gt;
pass through things. Tile collision lives in the &lt;strong&gt;TileSet&lt;/strong&gt; resource: open it, look&lt;br&gt;
under &lt;strong&gt;Physics Layers&lt;/strong&gt;, and set &lt;code&gt;collision_layer&lt;/code&gt; / &lt;code&gt;collision_mask&lt;/code&gt; there. Each&lt;br&gt;
physics layer of the TileSet has its own pair — a one-way platform layer and a solid&lt;br&gt;
wall layer can sit on different bits.&lt;/p&gt;

&lt;p&gt;Turn on &lt;strong&gt;Debug → Visible Collision Shapes&lt;/strong&gt; while you check; if the tile shapes don't&lt;br&gt;
draw, the tiles have no collision polygons painted yet and no layer setting will fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern behind all seven
&lt;/h2&gt;

&lt;p&gt;Every tip above is bookkeeping: deciding, once, who is what and who looks for whom, and&lt;br&gt;
then never letting a raw number decide it again. For combat specifically, that table&lt;br&gt;
in tip 1 is the part people rebuild in every project — hitbox masks, hurtbox layers,&lt;br&gt;
which team hits which.&lt;/p&gt;

&lt;p&gt;Saltmire Hitbox Lite is a free, MIT &lt;code&gt;Hitbox2D&lt;/code&gt; / &lt;code&gt;Hurtbox2D&lt;/code&gt; pair that handles that&lt;br&gt;
table for you: you set a team on each box and it wires the layers, so a player's sword&lt;br&gt;
only finds enemy hurtboxes without you touching a single checkbox. If you'd rather keep&lt;br&gt;
your own layer plan, the tips above are everything it does under the hood.&lt;/p&gt;




&lt;p&gt;If you'd rather drop this in than build it, &lt;strong&gt;Saltmire Hitbox Lite&lt;/strong&gt; does it as a ready-made tool: &lt;a href="https://saltmire.itch.io/saltmire-hitbox-lite" rel="noopener noreferrer"&gt;https://saltmire.itch.io/saltmire-hitbox-lite&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://saltmire.github.io/godot-4-collision-layers-masks-tips.html" rel="noopener noreferrer"&gt;https://saltmire.github.io/godot-4-collision-layers-masks-tips.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to save game settings (volume, fullscreen, keybinds) in Godot 4 with ConfigFile</title>
      <dc:creator>saltmire</dc:creator>
      <pubDate>Thu, 01 Oct 2026 12:26:35 +0000</pubDate>
      <link>https://dev.to/saltmire/how-to-save-game-settings-volume-fullscreen-keybinds-in-godot-4-with-configfile-abp</link>
      <guid>https://dev.to/saltmire/how-to-save-game-settings-volume-fullscreen-keybinds-in-godot-4-with-configfile-abp</guid>
      <description>&lt;p&gt;Every player opens the options menu, turns the music down, and expects it to &lt;em&gt;stay&lt;/em&gt; down&lt;br&gt;
next time. In Godot 4 the right tool for that is not your save system and not JSON — it's&lt;br&gt;
&lt;code&gt;ConfigFile&lt;/code&gt;, a tiny INI-style file that Godot reads and writes in one call.&lt;/p&gt;

&lt;p&gt;This tutorial builds a &lt;code&gt;Settings&lt;/code&gt; autoload that stores volume, fullscreen, vsync and&lt;br&gt;
keyboard rebinds in &lt;code&gt;user://settings.cfg&lt;/code&gt; and re-applies them every time the game starts.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why ConfigFile for settings
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It's human-readable (&lt;code&gt;[audio]&lt;/code&gt; / &lt;code&gt;master=0.8&lt;/code&gt;), so you can debug it by opening the file.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_value(section, key, default)&lt;/code&gt; gives you a fallback for free — a missing key never crashes.&lt;/li&gt;
&lt;li&gt;Settings are &lt;strong&gt;per machine&lt;/strong&gt;, not per save slot. Keeping them out of your game save means
deleting a slot never resets the player's volume.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Step 1: the Settings autoload
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;settings.gd&lt;/code&gt; and register it in &lt;strong&gt;Project → Project Settings → Globals → Autoload&lt;/strong&gt;&lt;br&gt;
with the name &lt;code&gt;Settings&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;PATH&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"user://settings.cfg"&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ConfigFile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&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;var&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PATH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;OK&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;ERR_FILE_NOT_FOUND&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;push_warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Settings: could not read &lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt; (error &lt;/span&gt;&lt;span class="si"&gt;%d&lt;/span&gt;&lt;span class="s2"&gt;), using defaults"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;apply_all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;get_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;section&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default_value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Variant&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;Variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;section&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default_value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;set_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;section&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Variant&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="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;section&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;save&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;var&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PATH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;push_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Settings: save failed (error &lt;/span&gt;&lt;span class="si"&gt;%d&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;apply_all&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="n"&gt;apply_volume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Master"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;apply_volume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Music"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;apply_volume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SFX"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;apply_display&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;apply_keybinds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ERR_FILE_NOT_FOUND&lt;/code&gt; on the very first launch is normal — there's no file yet, so every&lt;br&gt;
&lt;code&gt;get_value&lt;/code&gt; falls back to its default.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2: audio volume per bus
&lt;/h2&gt;

&lt;p&gt;Store volume as a &lt;strong&gt;linear 0.0–1.0&lt;/strong&gt; value (what a slider gives you) and convert to&lt;br&gt;
decibels only when applying. Make sure the buses &lt;code&gt;Music&lt;/code&gt; and &lt;code&gt;SFX&lt;/code&gt; exist in the Audio tab,&lt;br&gt;
or skip them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;apply_volume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bus_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&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;var&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AudioServer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_bus_index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bus_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"audio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bus_name&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to_lower&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;AudioServer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_bus_volume_db&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;linear_to_db&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;AudioServer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_bus_mute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;linear&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.001&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mute line matters: &lt;code&gt;linear_to_db(0.0)&lt;/code&gt; is &lt;code&gt;-inf&lt;/code&gt;, and muting the bus at zero is cleaner&lt;br&gt;
than relying on it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: fullscreen and vsync
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;apply_display&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;var&lt;/span&gt; &lt;span class="n"&gt;fullscreen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"fullscreen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;DisplayServer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window_set_mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;DisplayServer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WINDOW_MODE_FULLSCREEN&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fullscreen&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;DisplayServer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WINDOW_MODE_WINDOWED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;vsync&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"vsync"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;DisplayServer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window_set_vsync_mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;DisplayServer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VSYNC_ENABLED&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;vsync&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;DisplayServer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VSYNC_DISABLED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;On the web export, fullscreen requires a user gesture, so call this from a button press&lt;br&gt;
rather than only at startup.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4: keyboard rebinds that persist
&lt;/h2&gt;

&lt;p&gt;Storing whole &lt;code&gt;InputEvent&lt;/code&gt; objects works but makes the file noisy. For keyboard bindings,&lt;br&gt;
the physical keycode (an &lt;code&gt;int&lt;/code&gt;) is enough and survives keyboard layout changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;REBINDABLE&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"move_left"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"move_right"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"jump"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"attack"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;rebind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;InputEventKey&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="c1"&gt;# Replace only the keyboard event; keep gamepad bindings intact.&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;InputMap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action_get_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;InputEventKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;InputMap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action_erase_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;InputMap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action_add_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;set_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"keybinds"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;physical_keycode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;apply_keybinds&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;action&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;REBINDABLE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"keybinds"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&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;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;  &lt;span class="c1"&gt;# never rebound — keep the Input Map default&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;InputEventKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;physical_keycode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;InputMap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action_get_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;InputEventKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;InputMap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action_erase_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;InputMap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action_add_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To capture the new key, put a "press any key" state in your rebind button and grab the&lt;br&gt;
next &lt;code&gt;InputEventKey&lt;/code&gt; in &lt;code&gt;_unhandled_input&lt;/code&gt;, then call &lt;code&gt;Settings.rebind(action, event)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: wire the options menu
&lt;/h2&gt;



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

&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;onready&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;music_slider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HSlider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;MusicSlider&lt;/span&gt;
&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;onready&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;fullscreen_check&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CheckButton&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;FullscreenCheck&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&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="n"&gt;music_slider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Settings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"audio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"music"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fullscreen_check&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;button_pressed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Settings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"fullscreen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;music_slider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value_changed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&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="n"&gt;Settings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"audio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"music"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;Settings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;apply_volume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Music"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;music_slider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drag_ended&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_changed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;bool&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="n"&gt;Settings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="n"&gt;fullscreen_check&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;toggled&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;bool&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="n"&gt;Settings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"fullscreen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;Settings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;apply_display&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;Settings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the slider's range to &lt;code&gt;0&lt;/code&gt;–&lt;code&gt;1&lt;/code&gt; with a step of &lt;code&gt;0.05&lt;/code&gt;. Apply on every &lt;code&gt;value_changed&lt;/code&gt;&lt;br&gt;
so the player hears the change live, but only write to disk on &lt;code&gt;drag_ended&lt;/code&gt; — no need to&lt;br&gt;
hit the file system 60 times a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Saving to &lt;code&gt;res://&lt;/code&gt;.&lt;/strong&gt; It's read-only in exported games. Always use &lt;code&gt;user://&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Applying settings in the menu scene only.&lt;/strong&gt; If the menu isn't loaded, nothing applies.
That's why &lt;code&gt;apply_all()&lt;/code&gt; runs in the autoload's &lt;code&gt;_ready()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storing decibels.&lt;/strong&gt; Sliders feel wrong in dB; store linear, convert on apply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixing settings into the save slot.&lt;/strong&gt; One file per machine for settings, one per slot
for progress.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ConfigFile&lt;/code&gt; covers the options menu. Your actual game progress — player stats, inventory,&lt;br&gt;
unlocked levels — is a different job, and that's the part where writing your own&lt;br&gt;
serialization gets tedious. If you'd rather save and load that state in a single call,&lt;br&gt;
Saltmire Save Lite is a free, MIT-licensed add-on that does exactly that.&lt;/p&gt;




&lt;p&gt;If you'd rather drop this in than build it, &lt;strong&gt;Saltmire Save Lite&lt;/strong&gt; does it as a ready-made tool: &lt;a href="https://saltmire.itch.io/saltmire-save-lite" rel="noopener noreferrer"&gt;https://saltmire.itch.io/saltmire-save-lite&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://saltmire.github.io/godot-4-save-settings-configfile.html" rel="noopener noreferrer"&gt;https://saltmire.github.io/godot-4-save-settings-configfile.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>Godot 4 change_scene_to_file not working? 4 causes and fixes</title>
      <dc:creator>saltmire</dc:creator>
      <pubDate>Sun, 27 Sep 2026 13:15:06 +0000</pubDate>
      <link>https://dev.to/saltmire/godot-4-changescenetofile-not-working-4-causes-and-fixes-11kb</link>
      <guid>https://dev.to/saltmire/godot-4-changescenetofile-not-working-4-causes-and-fixes-11kb</guid>
      <description>&lt;p&gt;Changing scenes in Godot 4 looks like a one-liner: &lt;code&gt;get_tree().change_scene_to_file("res://level_2.tscn")&lt;/code&gt;.&lt;br&gt;
Then the door trigger throws an error, the game crashes one line later, or the build&lt;br&gt;
you exported loads a black screen. The call itself is almost never the bug — what&lt;br&gt;
happens &lt;em&gt;around&lt;/em&gt; it is. Here are the four causes, ordered by how often they show up.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Code after the call runs on a scene that's already gone
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; The scene changes, but you get&lt;br&gt;
&lt;code&gt;Cannot call method 'get_first_node_in_group' on a null value&lt;/code&gt; (or any&lt;br&gt;
&lt;code&gt;get_tree()&lt;/code&gt;-based call failing) on the line right after &lt;code&gt;change_scene_to_file()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; Since Godot 4.2, the outgoing scene is removed from the tree&lt;br&gt;
&lt;em&gt;immediately&lt;/em&gt; when you call &lt;code&gt;change_scene_to_file()&lt;/code&gt;. From that point until the end&lt;br&gt;
of the frame, &lt;code&gt;get_tree()&lt;/code&gt; on that scene returns &lt;code&gt;null&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;get_tree().current_scene&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; too — the new scene isn't added until the end&lt;br&gt;
of the frame. So anything you do after the call is running on a node that's no&lt;br&gt;
longer in the tree.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# broken — get_tree() is null on the second line&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_on_exit_reached&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="n"&gt;get_tree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change_scene_to_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"res://levels/level_2.tscn"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;get_tree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_scene&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spawn_player&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Make the scene change the &lt;em&gt;last&lt;/em&gt; thing the old scene does. Anything the new&lt;br&gt;
scene needs goes into an autoload beforehand, and the new scene reads it in its own&lt;br&gt;
&lt;code&gt;_ready()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# old scene&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_on_exit_reached&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="n"&gt;GameState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spawn_point&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"north_door"&lt;/span&gt;   &lt;span class="c1"&gt;# autoload survives the change&lt;/span&gt;
    &lt;span class="n"&gt;get_tree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change_scene_to_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"res://levels/level_2.tscn"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# new scene (level_2.gd)&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&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;var&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Spawns/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;GameState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spawn_point&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;Marker2D&lt;/span&gt;
    &lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="n"&gt;Player&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;global_position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;global_position&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. "Can't change this state while flushing queries"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; You change scene from a &lt;code&gt;body_entered&lt;/code&gt; / &lt;code&gt;area_entered&lt;/code&gt; signal (a door,&lt;br&gt;
a level exit, a death pit) and the output fills with&lt;br&gt;
&lt;code&gt;Can't change this state while flushing queries. Use call_deferred() or set_deferred() to change monitoring state instead.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; Those signals fire &lt;em&gt;during&lt;/em&gt; the physics step, while the physics server is&lt;br&gt;
still processing collisions. Changing the scene removes every &lt;code&gt;Area2D&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;CollisionShape2D&lt;/code&gt; in it right then — in the middle of the step that's iterating&lt;br&gt;
over them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Defer the call so it runs after the physics step finishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_on_body_entered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Node2D&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;if&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_in_group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"player"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;get_tree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change_scene_to_file&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call_deferred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"res://levels/level_2.tscn"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same rule applies to anything else that adds or frees collision nodes from inside&lt;br&gt;
a physics callback — defer it.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. The path breaks after a rename — or only in the exported build
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; It worked yesterday, now nothing happens. Or it works perfectly in the&lt;br&gt;
editor but the exported game shows a black screen or stays on the same scene.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; &lt;code&gt;change_scene_to_file()&lt;/code&gt; doesn't throw — it returns an &lt;code&gt;Error&lt;/code&gt;, and most&lt;br&gt;
code ignores it. Two common reasons it fails:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You moved or renamed the &lt;code&gt;.tscn&lt;/code&gt; in the FileSystem dock. The editor updates
references inside scenes, but &lt;strong&gt;not&lt;/strong&gt; string literals in your scripts.&lt;/li&gt;
&lt;li&gt;Case mismatch: &lt;code&gt;res://Levels/Level_2.tscn&lt;/code&gt; vs &lt;code&gt;res://levels/level_2.tscn&lt;/code&gt;. Windows
and macOS don't care in the editor; the exported &lt;code&gt;.pck&lt;/code&gt; does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; First, stop ignoring the return value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_tree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change_scene_to_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;push_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Scene change to &lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt; failed: &lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then remove the fragile string entirely. Export a &lt;code&gt;PackedScene&lt;/code&gt; and drag the file into&lt;br&gt;
the Inspector — Godot tracks it by UID, so renames and moves keep working:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;next_scene&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PackedScene&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;go_next&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="n"&gt;get_tree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change_scene_to_packed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call_deferred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next_scene&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. The change fires twice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Your fade plays twice, a level gets skipped, or you get a second error&lt;br&gt;
right after the first scene change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; The player's body often overlaps a trigger with more than one shape, or&lt;br&gt;
re-enters it on the same frame, so &lt;code&gt;body_entered&lt;/code&gt; fires more than once. Each call&lt;br&gt;
queues another scene change — and if you have a fade tween in front of it, you now&lt;br&gt;
have two tweens racing each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Guard it with a flag and switch the trigger off on the first hit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;_leaving&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;false&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_on_body_entered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Node2D&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;if&lt;/span&gt; &lt;span class="n"&gt;_leaving&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_in_group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"player"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;_leaving&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;
    &lt;span class="n"&gt;set_deferred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"monitoring"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;get_tree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change_scene_to_packed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call_deferred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next_scene&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What all four have in common
&lt;/h2&gt;

&lt;p&gt;A scene change is a destructive operation on the whole tree: it frees every node,&lt;br&gt;
including the one that asked for it. Treat it like &lt;code&gt;queue_free()&lt;/code&gt; on yourself —&lt;br&gt;
do it last, do it deferred when physics is involved, point it at a tracked&lt;br&gt;
resource instead of a string, and make sure it only happens once.&lt;/p&gt;

&lt;p&gt;Once that's solid, the next thing most games want is a transition that hides the&lt;br&gt;
cut — and wiring a fade, a loading step and the deferred change together is exactly&lt;br&gt;
where these four bugs love to come back. Saltmire Transitions packages that part&lt;br&gt;
into a single call, with the fades, wipes and the safe scene swap already handled,&lt;br&gt;
and it's free.&lt;/p&gt;




&lt;p&gt;If you'd rather drop this in than build it, &lt;strong&gt;Saltmire Transitions&lt;/strong&gt; does it as a ready-made tool: &lt;a href="https://saltmire.itch.io/saltmire-transitions-scene-transition-kit-for-godot-4" rel="noopener noreferrer"&gt;https://saltmire.itch.io/saltmire-transitions-scene-transition-kit-for-godot-4&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://saltmire.github.io/godot-4-change-scene-not-working.html" rel="noopener noreferrer"&gt;https://saltmire.github.io/godot-4-change-scene-not-working.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>What I learned building a health component with i-frames in Godot 4</title>
      <dc:creator>saltmire</dc:creator>
      <pubDate>Fri, 25 Sep 2026 12:26:33 +0000</pubDate>
      <link>https://dev.to/saltmire/what-i-learned-building-a-health-component-with-i-frames-in-godot-4-3b78</link>
      <guid>https://dev.to/saltmire/what-i-learned-building-a-health-component-with-i-frames-in-godot-4-3b78</guid>
      <description>&lt;p&gt;Every Godot 4 project I start ends up with &lt;code&gt;var health := 100&lt;/code&gt; pasted into the player, then&lt;br&gt;
the enemy, then the breakable crate. Then the three copies drift apart. Here is what I&lt;br&gt;
learned pulling health out into one reusable component, roughly in the order each bug&lt;br&gt;
showed up.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lesson 1: health is not the player's job
&lt;/h2&gt;

&lt;p&gt;The first version always lives inside the player script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;health&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;take_damage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&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="n"&gt;health&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;health&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;die&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's fine until an enemy needs the same logic, then a destructible barrel, then a boss&lt;br&gt;
with a health bar. Once there are three copies, one gets clamping, one gets a signal for&lt;br&gt;
the UI, and one gets neither. The fix is a small node you can drop into any scene:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;class_name&lt;/span&gt; &lt;span class="n"&gt;HealthComponent&lt;/span&gt;
&lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Node&lt;/span&gt;

&lt;span class="k"&gt;signal&lt;/span&gt; &lt;span class="n"&gt;health_changed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_health&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;signal&lt;/span&gt; &lt;span class="n"&gt;died&lt;/span&gt;

&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;max_health&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;max_health&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&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="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;max_health&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;damage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&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="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clampi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;amount&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;span class="n"&gt;max_health&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;health_changed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_health&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;died&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;heal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&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="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clampi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;amount&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;span class="n"&gt;max_health&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;health_changed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_health&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The owner (player, enemy, crate) connects to &lt;code&gt;died&lt;/code&gt; and decides what death &lt;em&gt;means&lt;/em&gt;. The&lt;br&gt;
component only counts. That split turned out to matter more than any of the code.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lesson 2: the health bar should never ask for health
&lt;/h2&gt;

&lt;p&gt;My early UI code read &lt;code&gt;player.health&lt;/code&gt; every frame in &lt;code&gt;_process&lt;/code&gt;. That works, but it ties&lt;br&gt;
the health bar to one specific node and breaks the moment the player is freed and&lt;br&gt;
respawned. With the signal, the bar just listens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;health&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HealthComponent&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="n"&gt;health&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;health_changed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_on_health_changed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_on_health_changed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;health&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;health&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_health&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_on_health_changed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_health&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&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="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;max_health&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;100.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling the handler once inside &lt;code&gt;bind()&lt;/code&gt; is the part I kept forgetting. Without it the bar&lt;br&gt;
shows 100% until the first hit, even if the scene loaded with the player at half health.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lesson 3: the double-hit bug is what i-frames actually fix
&lt;/h2&gt;

&lt;p&gt;I thought invincibility frames were a design choice, something that makes the game feel&lt;br&gt;
fairer. In practice they fixed a real bug first. A spike hitbox overlapping the player&lt;br&gt;
for 12 physics frames dealt damage 12 times, and a single sword swing could hit twice&lt;br&gt;
when the enemy's hurtbox entered, left, and re-entered the area during the animation.&lt;/p&gt;

&lt;p&gt;A timer inside the component fixes both at the source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;iframe_duration&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;_invulnerable&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;false&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;damage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&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;if&lt;/span&gt; &lt;span class="n"&gt;_invulnerable&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clampi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;amount&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;span class="n"&gt;max_health&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;health_changed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_health&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;died&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;iframe_duration&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;_invulnerable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;
        &lt;span class="n"&gt;get_tree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_timer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iframe_duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="n"&gt;_invulnerable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enemies in a horde game usually want &lt;code&gt;iframe_duration = 0.0&lt;/code&gt; (or something very short)&lt;br&gt;
so fast weapons still feel fast. The player usually wants 0.5–1.0 seconds. Making it an&lt;br&gt;
export instead of hard-coding it was what let one component work for both.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lesson 4: "died" must fire exactly once
&lt;/h2&gt;

&lt;p&gt;This bug wasted an evening. Two projectiles hit an enemy on the same physics frame. Both&lt;br&gt;
called &lt;code&gt;damage()&lt;/code&gt;, both saw &lt;code&gt;current == 0&lt;/code&gt; afterwards, and &lt;code&gt;died&lt;/code&gt; fired twice. The enemy&lt;br&gt;
dropped two coins, the kill counter went up by two, and the second &lt;code&gt;queue_free()&lt;/code&gt; threw&lt;br&gt;
an error on an instance that was already queued.&lt;/p&gt;

&lt;p&gt;That's why the check at the top of &lt;code&gt;damage()&lt;/code&gt; has &lt;code&gt;or current == 0&lt;/code&gt;. Once health reaches&lt;br&gt;
zero, the component ignores everything after it. It's one condition, and it's also the&lt;br&gt;
only place that condition needs to exist. Anything listening to &lt;code&gt;died&lt;/code&gt; can assume it runs&lt;br&gt;
once per life.&lt;/p&gt;

&lt;p&gt;If you add a respawn, give it an explicit reset instead of healing from zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;reset&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="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;max_health&lt;/span&gt;
    &lt;span class="n"&gt;_invulnerable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;false&lt;/span&gt;
    &lt;span class="n"&gt;health_changed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_health&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;heal()&lt;/code&gt; on a dead entity was the other source of zombies. I made healing from 0 do&lt;br&gt;
nothing, and revival only happens on purpose.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lesson 5: flash the sprite from the owner, not the component
&lt;/h2&gt;

&lt;p&gt;The component should not know there's a sprite. When I put &lt;code&gt;modulate&lt;/code&gt; changes inside it,&lt;br&gt;
it broke immediately on the crate, which used a different node structure. Now the owner&lt;br&gt;
reacts to the signal it already has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&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="o"&gt;$&lt;/span&gt;&lt;span class="n"&gt;HealthComponent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;health_changed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_m&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;_flash&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_flash&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;var&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;create_tween&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="n"&gt;Sprite2D&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modulate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tween_property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="n"&gt;Sprite2D&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"modulate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WHITE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd do differently starting over
&lt;/h2&gt;

&lt;p&gt;Build the component first, before the second thing that can take damage exists. Put&lt;br&gt;
i-frames and the "dead means dead" guard in from day one, because both bugs show up the&lt;br&gt;
first time two hits land close together. And keep the component dumb: it counts, emits,&lt;br&gt;
and refuses. Everything visual belongs to whoever owns it.&lt;/p&gt;

&lt;p&gt;The part this post skips is the other half: &lt;em&gt;who&lt;/em&gt; is allowed to hit &lt;em&gt;whom&lt;/em&gt;. That's&lt;br&gt;
collision layers, teams, making sure the player's own sword doesn't hurt the player, and&lt;br&gt;
knockback direction. That's the wiring I got tired of redoing in every project.&lt;/p&gt;




&lt;p&gt;If you'd rather drop this in than build it, &lt;strong&gt;Saltmire Hitbox&lt;/strong&gt; does it as a ready-made tool: &lt;a href="https://saltmire.itch.io/saltmire-hitbox" rel="noopener noreferrer"&gt;https://saltmire.itch.io/saltmire-hitbox&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://saltmire.github.io/godot-4-health-component-iframes.html" rel="noopener noreferrer"&gt;https://saltmire.github.io/godot-4-health-component-iframes.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>Area2D vs RayCast2D vs ShapeCast2D for melee attacks in Godot 4 — which and when</title>
      <dc:creator>saltmire</dc:creator>
      <pubDate>Wed, 23 Sep 2026 12:27:08 +0000</pubDate>
      <link>https://dev.to/saltmire/area2d-vs-raycast2d-vs-shapecast2d-for-melee-attacks-in-godot-4-which-and-when-2go2</link>
      <guid>https://dev.to/saltmire/area2d-vs-raycast2d-vs-shapecast2d-for-melee-attacks-in-godot-4-which-and-when-2go2</guid>
      <description>&lt;p&gt;You press attack, the sword animation plays, and now the game has to answer one&lt;br&gt;
question: &lt;em&gt;what did that swing touch?&lt;/em&gt; Godot 4 gives you three nodes that can answer it —&lt;br&gt;
&lt;code&gt;Area2D&lt;/code&gt;, &lt;code&gt;RayCast2D&lt;/code&gt; and &lt;code&gt;ShapeCast2D&lt;/code&gt; — and every tutorial seems to pick a different&lt;br&gt;
one. They are not interchangeable. Each one answers the question at a different &lt;em&gt;time&lt;/em&gt;&lt;br&gt;
and with a different &lt;em&gt;shape&lt;/em&gt;, and that is the whole decision.&lt;/p&gt;
&lt;h2&gt;
  
  
  Area2D: an overlap you listen to
&lt;/h2&gt;

&lt;p&gt;An &lt;code&gt;Area2D&lt;/code&gt; with a &lt;code&gt;CollisionShape2D&lt;/code&gt; child sits in the world and emits signals when&lt;br&gt;
other areas or bodies enter it. For melee, you enable it during the active frames of the&lt;br&gt;
attack and listen for &lt;code&gt;area_entered&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Area2D&lt;/span&gt; &lt;span class="c1"&gt;# the sword's hitbox&lt;/span&gt;

&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;damage&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&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="n"&gt;monitoring&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;false&lt;/span&gt;
    &lt;span class="n"&gt;area_entered&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_on_area_entered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;start_swing&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="n"&gt;set_deferred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"monitoring"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;end_swing&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="n"&gt;set_deferred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"monitoring"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_on_area_entered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Area2D&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;if&lt;/span&gt; &lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"take_hit"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;take_hit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;damage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strengths: it's visual (you size the shape in the editor), it tracks lingering hitboxes&lt;br&gt;
for free (a fire patch, a spinning blade), and it plays nicely with collision layers.&lt;/p&gt;

&lt;p&gt;The catch everyone hits eventually: &lt;strong&gt;Area2D results lag by one physics frame.&lt;/strong&gt; Overlaps&lt;br&gt;
are computed during the physics step, so if you turn &lt;code&gt;monitoring&lt;/code&gt; on and immediately call&lt;br&gt;
&lt;code&gt;get_overlapping_areas()&lt;/code&gt; in the same frame, you get an empty array. For a 3-frame jab at&lt;br&gt;
60 FPS, losing one frame is a third of your active window. That's the source of the&lt;br&gt;
classic "my attack only hits sometimes" bug.&lt;/p&gt;
&lt;h2&gt;
  
  
  RayCast2D: a line, checked right now
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;RayCast2D&lt;/code&gt; tests a single line from its origin to &lt;code&gt;target_position&lt;/code&gt;. Its killer&lt;br&gt;
feature for combat is &lt;code&gt;force_raycast_update()&lt;/code&gt; — it queries the physics space&lt;br&gt;
&lt;strong&gt;immediately&lt;/strong&gt;, in the same frame.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;onready&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ray&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RayCast2D&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="n"&gt;RayCast2D&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;fire_hitscan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reach&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&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="n"&gt;ray&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target_position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;reach&lt;/span&gt;
    &lt;span class="n"&gt;ray&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collide_with_areas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;
    &lt;span class="n"&gt;ray&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;force_raycast_update&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ray&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_colliding&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ray&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_collider&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;point&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ray&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_collision_point&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;# great for spawning a spark&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"take_hit"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;take_hit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Perfect for hitscan guns, lasers, line-of-sight checks and spear thrusts. Bad for a wide&lt;br&gt;
sword arc: a line has no thickness, so a swing that visually clips an enemy's shoulder&lt;br&gt;
will miss if the ray passes a pixel beside it. It also only returns the &lt;strong&gt;first&lt;/strong&gt;&lt;br&gt;
thing it hits — no piercing without extra work.&lt;/p&gt;
&lt;h2&gt;
  
  
  ShapeCast2D: a shape, swept and checked right now
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ShapeCast2D&lt;/code&gt; (new in Godot 4) is the middle ground: it sweeps a real shape along&lt;br&gt;
&lt;code&gt;target_position&lt;/code&gt; and, like the ray, can be forced to update on the spot. Set&lt;br&gt;
&lt;code&gt;target_position&lt;/code&gt; to &lt;code&gt;Vector2.ZERO&lt;/code&gt; and it becomes an instant "what overlaps this shape&lt;br&gt;
right now" query.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;onready&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ShapeCast2D&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="n"&gt;ShapeCast2D&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;swing&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="n"&gt;cast&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target_position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ZERO&lt;/span&gt; &lt;span class="c1"&gt;# instant overlap, no sweep&lt;/span&gt;
    &lt;span class="n"&gt;cast&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collide_with_areas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;
    &lt;span class="n"&gt;cast&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;force_shapecast_update&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="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cast&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_collision_count&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cast&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_collider&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"take_hit"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;take_hit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has thickness like an Area2D, answers in the same frame like a RayCast2D, and returns&lt;br&gt;
&lt;strong&gt;multiple&lt;/strong&gt; hits (capped by &lt;code&gt;max_results&lt;/code&gt;). The trade-off: it's a one-shot query, not a&lt;br&gt;
listener — nothing tells you when something walks into a lingering hitbox later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Area2D&lt;/th&gt;
&lt;th&gt;RayCast2D&lt;/th&gt;
&lt;th&gt;ShapeCast2D&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shape&lt;/td&gt;
&lt;td&gt;any, has area&lt;/td&gt;
&lt;td&gt;a line&lt;/td&gt;
&lt;td&gt;any, has area&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When results are ready&lt;/td&gt;
&lt;td&gt;next physics frame&lt;/td&gt;
&lt;td&gt;same frame (forced)&lt;/td&gt;
&lt;td&gt;same frame (forced)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple targets&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;first hit only&lt;/td&gt;
&lt;td&gt;yes (&lt;code&gt;max_results&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lingering hitboxes&lt;/td&gt;
&lt;td&gt;built in (signals)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no — you re-query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collision point&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes, per hit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fast projectiles (tunneling)&lt;/td&gt;
&lt;td&gt;can tunnel&lt;/td&gt;
&lt;td&gt;sweeps, won't tunnel&lt;/td&gt;
&lt;td&gt;sweeps, won't tunnel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;hurtboxes, AoE zones, slow attacks&lt;/td&gt;
&lt;td&gt;hitscan, lasers, LOS&lt;/td&gt;
&lt;td&gt;instant melee, dashes, fast bullets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The rule that decides it
&lt;/h2&gt;

&lt;p&gt;Ask two questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does the hit need to land on the exact frame the button was pressed?&lt;/strong&gt; Short jabs,
parries and hitscan do. Use a forced &lt;code&gt;RayCast2D&lt;/code&gt; (thin) or &lt;code&gt;ShapeCast2D&lt;/code&gt; (wide).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the hitbox stay alive and keep hurting things that walk into it?&lt;/strong&gt; Fire, spikes,
a spinning blade, and — importantly — every character's &lt;strong&gt;hurtbox&lt;/strong&gt;. That's &lt;code&gt;Area2D&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most shipped action games end up with both: &lt;code&gt;Area2D&lt;/code&gt; hurtboxes on every character, and&lt;br&gt;
the &lt;em&gt;attack&lt;/em&gt; side checked with whatever fits the move. What they all need on top, no&lt;br&gt;
matter which node detects the overlap, is the same boring layer: teams so enemies don't&lt;br&gt;
hit each other, a per-swing "already hit" list so one slash doesn't deal damage five&lt;br&gt;
frames in a row, i-frames, and knockback direction.&lt;/p&gt;

&lt;p&gt;That layer is what Saltmire Hitbox is — a drop-in Hitbox2D / Hurtbox2D pair with teams,&lt;br&gt;
per-swing hit tracking, i-frames and knockback already wired, so you pick the detection&lt;br&gt;
style above and skip writing the bookkeeping for the third time.&lt;/p&gt;




&lt;p&gt;If you'd rather drop this in than build it, &lt;strong&gt;Saltmire Hitbox&lt;/strong&gt; does it as a ready-made tool: &lt;a href="https://saltmire.itch.io/saltmire-hitbox" rel="noopener noreferrer"&gt;https://saltmire.itch.io/saltmire-hitbox&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://saltmire.github.io/godot-4-area2d-vs-raycast-vs-shapecast.html" rel="noopener noreferrer"&gt;https://saltmire.github.io/godot-4-area2d-vs-raycast-vs-shapecast.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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