Skip to main content
Most of a Summer project is text you can write yourself. Scenes, resources, project settings and translations are all authorable from a script or straight from disk, which is what makes an agent able to build a game without a person clicking anything. This page is about what to write. Headless Scripting covers how to run the engine from a shell to write it — the script template, exit codes, the import pass. Read that one first if you have not. Four places the “just write the file” approach stops working, all of them measured against the shipped binary rather than reasoned about:

The failure shape to watch for

Nearly every trap on this page has the same signature, and it is worth naming because once you recognise it you will start catching the others yourself:
Your own read-back confirms a change that did not take.
Not an error. Not a crash. You set a value, you read it back to check, the check passes, and the thing you asked for did not happen:
  • ProjectSettings.set_setting() returns fine and reads back fine, and the value is gone next run because nothing called save().
  • A feature-tag override is live and driving the engine, while get_setting() reports the base value and tells you it did not apply.
  • PathFollow2D.progress set before the node is in the tree reads back exactly what you set, and the node never moves.
  • PackedScene.pack() and ResourceSaver.save() both return success and write a scene with your nodes missing.
  • FontFile.load_dynamic_font() returns error code 0 having loaded zero faces.
This is a worse class of bug than a loud failure, because the natural instinct — verify by reading it back — is precisely what fails. Verify by the effect, not by the value. Did the node move? Does the reloaded scene have the child? Does the exported build actually contain the file? Those questions cannot be answered by the same in-memory state that lied to you.

Where user:// actually goes

A save file written to user://save.json lands here:
Note Godot, not Summer. This is the same surprise as the export-template directory described on the headless page — our product name is not in the path. “I wrote a save file and it is not there” almost always ends here.
The <name> segment comes from config/name in project.godot, so it changes when the project is renamed. Do not construct the path. Ask the engine:
That is measured output for a project whose config/name is AuthoringProbe.

Project settings and feature-tag overrides

project.godot supports per-platform overrides with a feature-tag suffix — difficulty.macos overrides difficulty when running on macOS. They work. They are also invisible to the most obvious way of checking them. Given a project.godot containing:
running on macOS:
ProjectSettings.get_setting() does not apply feature-tag overrides. get_setting_with_override() does.This is nastier than a plain gotcha because the engine’s own internal reads do apply the override. The setting genuinely works at runtime while your verification step reports that it did not. It fails by disagreeing with your check, not by breaking — so the usual response is to “fix” a setting that was already correct.Measured on 4.6.1.stable.mono.custom_build.b708b1182.
Two rules:
  1. Read effective values with get_setting_with_override(). Use plain get_setting() only when you specifically want the unoverridden base value.
  2. Always define a base key alongside any override. An override on its own leaves the base key null — hard.macos=1 exists, hard does not. Anything reading hard gets null, including get_setting_with_override() on a platform that is not macOS.

Localisation: three formats, three rules

Three file formats reach the same place by different routes, and only one of them is comfortable to write by hand.
This is the format an agent should use. Write it, load it, done. No import pass, no sidecar, no .godot/ directory.
Loading it directly returns a real Translation:
The locale is read from the Language: header, so a .po file carries its own locale and you do not declare it anywhere else.
A translation .csv has a keys column and one column per locale:
It requires an import pass, and the importer writes sibling files, one per locale:
Load the sibling, never the .csv. See the warning below — this is the silent one.
Compiled output, not source. file reports it as binary, and the loaded resource is an OptimizedTranslation rather than a plain Translation:
You cannot hand-write one. Write a .po, or write a .csv and import it.
Loading a translation .csv returns null forever — and gets quieter after you import it.
Before importing, at least you get an error line. After importing, it returns null with no error whatsoever, because the importer registered the file as handled — so the state that looks most like success is the one that tells you least.The cause is in the sidecar. strings.csv.import has no path= key, only dest_files:
A single-output importer writes path=, which is what lets load() on the source file resolve to the compiled product. A translation .csv produces many outputs, so there is no single path to remap to, and load("res://strings.csv") has nothing to resolve.An agent must load the file the importer generated, not the file it wrote. Measured on 4.6.1.stable.mono.custom_build.b708b1182.
Register translations through the internationalization/locale/translations project setting. Both .po and .translation files can be listed:
Measured result — both locales load, and tr() resolves against whichever is current:
The default locale is the OS locale, not "en". It measured as en_DK on the test machine — a locale for which you almost certainly have no translation, and which is not the one you assumed.Set it explicitly if any behaviour depends on it:
The .csv route is the only one here that needs the import pass. Running it is not free and not always safe — see importing assets for the --import hazard, which matters if an editor is open. Writing a .po avoids the question entirely, which is the main reason to prefer it.

Saving scenes from a script

ResourceSaver.save(PackedScene) is the reliable way to produce a .tscn. It has one requirement that is easy to miss and fails quietly.
Every child must have its owner set before pack(), or it is not saved.PackedScene.pack() only serialises nodes owned by the node being packed. Miss it and the save still returns 0:
Same code, same success code, an empty scene. Nothing reports a failure at any point.
The root itself does not need an owner. Nested children take the scene root as owner, not their immediate parent.

Tilemaps: where hand-writing a .tscn stops

An agent can hand-write most of a .tscn. It cannot hand-write a tilemap. TileMapLayer cells do not serialise as readable text. The entire layer — every cell, its source, its atlas coordinate and its alternative — is one packed byte array on the node called tile_map_data. This is what it looks like in a real saved scene:
That is two cells.
Do not try to patch this blob with a text edit.There is no string in it to match on. Editing a cell means changing bytes at a computed offset, which a find-and-replace cannot express, and a real tilemap’s array runs to many thousands of characters — past the point where tolerant edit matching applies at all. A near-miss produces a misaligned array that still parses into some set of cells, so the failure arrives as garbled level geometry rather than an error.
Use set_cell() and let the engine write the bytes. This is the whole job:
Round-tripped through the shipped binary, get_used_cells() on the reloaded scene returns [(-3, 7), (11, -2)] — coordinates, sources, atlas positions and alternatives all intact.
Documented so you can read a blob — to diff two scenes, or to check what an editor-produced layer actually contains. It is not an invitation to write one by hand.A <H uint16 format version, measured as 0, followed by 12 bytes per cell:All little-endian. Cell order is insertion order, not sorted. The version field at the head is the thing to check before trusting any of this on a future build — if it is not 0, stop.A decoder, verified against engine-produced bytes:
Run against a 26-byte layer holding two cells with distinct sources, distinct non-zero alternatives and negative coordinates in both axes:
Which is exactly the layer that produced it.

Ordering and serialisation rules

Two more rules that share the shape of everything else on this page: the operation appears to succeed, or fails opaquely, rather than telling you what is wrong.

A Path2D must be in the tree before you position a PathFollow2D

progress set before the Path2D is in the scene tree is accepted and does nothing.The two properties fail differently, and the quiet one is the one you are more likely to use:The error you get from progress_ratio:
progress prints nothing at all. The property holds the value you assigned, so reading it back as a check confirms a placement that never happened.Measured on 4.6.1.stable.mono.custom_build.b708b1182.
Add the Path2D to the tree, let a frame pass, then set the position:
Without the add_child and the frame, that last line prints (0.0, 0.0).

A Resource defined as an inner class cannot be serialised

An inner-class Resource saves with error 0 and reloads with its script gone.
var_to_bytes_with_objects returns a zero-length array, and ResourceSaver.save reports success while writing something that comes back as a bare Resource with every exported property lost. An inner class has no resource path, so there is nothing to record.Measured on 4.6.1.stable.mono.custom_build.b708b1182.
Give the resource its own script file with a class_name:

For AI agents

Authoring project data directly works well. These are the parts that will burn you, and as on the headless page, every one of them fails by appearing to succeed:
  1. user:// is under Godot, not Summer. Call OS.get_user_data_dir() rather than building the path. The <name> segment is config/name and changes with the project.
  2. get_setting() hides feature-tag overrides. Use get_setting_with_override() for the effective value. The engine applies the override internally, so a naive check disagrees with reality and tempts you to “fix” a setting that was already right.
  3. Always write a base key next to any override. foo.macos=1 alone leaves foo null.
  4. Write .po, not .csv. .po loads directly with no import pass. load() on a translation .csv returns null before and after import — and after import it returns null with no error at all. Load the generated res://<name>.<locale>.translation sibling instead.
  5. Set the locale explicitly. The default is the OS locale, measured en_DK, not "en".
  6. Set owner on every child before pack(). Otherwise ResourceSaver.save returns 0 and writes an empty scene.
  7. Never text-edit tile_map_data. Call set_cell() and save a PackedScene. The blob is fixed-width binary with no strings to match on, and a misaligned edit yields plausible-looking garbage rather than an error.
  8. Put the Path2D in the tree before setting progress. Setting it early is silently ignored while the property still reads back the value you assigned.
  9. Never declare a Resource as an inner class. It saves with error 0 and reloads with its script and all exported properties gone. Give it a file and a class_name.