Native extensions
Advanced. Most projects should stay on script.txt / JSON. Use a native extension only when you need custom systems the declarative surface does not cover (inventory, custom parallax math, a one-off minigame).
A native extension is a GDScript entry file your project declares in game.json. The player compiles and loads it at startup. The runtime is built with Godot; this is the one handbook place that expects you to write GDScript against a small host API.
This page is the runtime integration map — how your Node lives inside the player. Method tables live on extensions (game.json).
When to use
| Prefer | When |
|---|---|
| Script / JSON / screens | Dialogue, flags, layouts, most FX (shake, flash, shaders) |
| First-class runtime feature (later) | Patterns many games would copy the same way |
| Native extension | Game-specific logic you will maintain yourself |
Mental model
Your entry Node → parented under reserved "Extensions" as ext_<id>
Visuals → children of host.get_plane("…") CanvasLayers
Story → host (flags, commands, jump, signals, blocker)
Screens / toast → host.push_screen / notify, or authored screens.json
Godot power → only on nodes you create (process, input, particles, …)
- Host is the stable contract. Prefer it over digging into private player nodes.
- Full engine power on your subtree is intentional (unsandboxed). That does not mean every scene path is supported.
- There is no long-term stability promise beyond the documented host API. Pin
runtime_minto the player version you tested.
Trust model
- Unsandboxed: files, network, process — anything the OS user can do.
- Author mode: toast after title listing loaded id(s) (“unrestricted code”).
- Production / shipped: silent load; pack is part of the product you publish.
- Author Refresh (F5) reloads extensions in author mode. Production still loads once at boot.
Example: examples/example-06-native-extension/.
Lifecycle
| Moment | What happens |
|---|---|
| Cold boot | After core wiring: load every extensions[] entry; call vn_extension_setup(host) once per success |
| Title | Phase "title"; modal title is up; your nodes already exist unless you hid them |
| Play | Phase "playing"; story flags/commands/signals fire |
| Session end → title | Input blocker cleared; story reloads; your extension Node is not torn down — reset on connect_story_ended if needed |
| F5 (author) | Free all Extensions children; unregister ext.* saves and ext_* command handlers; load_all again; toast |
| Production | No F5 reload — edit .gd → rebuild / restart the player |
There is no teardown / unload API beyond F5’s full reload. Design for long-lived nodes.
Where visuals and UI sit
Planes are fixed. You choose which plane your content attaches to via host.get_plane(id).
| Plane | Typical use from an extension |
|---|---|
bg | Parallax / under-character FX |
overlay | Over panel, under HUD/modals |
hud | Always-visible chrome (still under modals) |
notify | Custom toasts / banners above most UI |
modal | Draw over title/pause (use carefully — don’t steal all input) |
cursor / transition | Rare; easy to break chrome |
Full plane list for get_plane: extensions (game.json). Authored menus: Title, pause and settings screens + host.push_screen(id).
Title and pause are modal screens (high layer). Children on overlay stay under them. To appear above title, parent to modal or notify, or use host.notify.
Pause vs dialogue blocker
Two different gates:
| Mechanism | What it does | Extension tip |
|---|---|---|
Screen pauses_tree (pause menu) | Tree pauses — nodes with default process mode freeze | Set process_mode = Node.PROCESS_MODE_ALWAYS on your extension root and any child that must animate or take input while paused |
host.set_input_blocker(true) | Blocks dialogue advance and choice confirm only | UI screens and pause still work; call host.resume() or set_input_blocker(false) to clear |
host.resume() clears the input blocker only — not story history, not pause, not a return from jump.
Input
- Dialogue UI often consumes clicks for advance. Prefer
_input(not only_unhandled_input) when you need clicks during play — see ex06click_particles. - While a modal screen is up, world input is usually blocked; your node still needs a process mode that runs if you want events under pause.
- Gate by phase when FX should not run on title:
host.get_phase()→"title"|"playing"|"exit_commands"|"story_ending".
Story integration
| Need | Use |
|---|---|
| React to nodes / flags / end | connect_node_entered / connect_flag_changed / connect_story_ended |
| Private state in saves | register_save → stored as ext.<id>.<key>; restore runs after core |
| Drive from script | register_command("verb", …) → type ext_<id>_verb in Mode A or JSON |
| Rich command fields | Prefer JSON / Mode B objects; Mode A mainly gives "args" plus key=value tokens |
| Soft lock the line | set_input_blocker(true) then jump / resume when done |
| Toast | notify(message) (draws above modal) |
Flags are shared with the story — prefer names like ext_<id>_score. Check allowlists ext_* only for ids declared in game.json.
Recipes
Always-on FX (runs under pause)
func vn_extension_setup(host) -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
var plane := host.get_plane("overlay")
var fx := CPUParticles2D.new()
fx.process_mode = Node.PROCESS_MODE_ALWAYS
plane.add_child(fx)
Visible over title
Parent to host.get_plane("modal") or "notify", or call host.notify("…"). Still set PROCESS_MODE_ALWAYS if the title path ever pauses the tree.
Minigame beat
host.set_input_blocker(true)- Show your UI on a plane (or
push_screenan authored screen id) - On success:
host.set_flag(…),host.jump("next_node"),host.resume()
Title-only decoration
func _process(_dt: float) -> void:
visible = (host.get_phase() == "title")
(Keep a host reference from setup; or poll only when you care.)
Command-driven effect
host.register_command("burst", func(cmd: Dictionary) -> void:
# cmd.type == "ext_my_id_burst"; Mode A may set cmd.args
pass
)
Story: [ext_my_id_burst power=2] or { "type": "ext_my_id_burst", "power": 2 }.
Do / don’t
| Do | Don’t |
|---|---|
Parent visuals via get_plane | Assume deep paths into private player UI are stable |
| Use Host for story, save, screens, blocker | Expect Host to wrap every engine API |
Reset on connect_story_ended | Assume session end frees your extension |
Author new menus in screens.json | Clone title by digging screen-manager internals |
Namespace flags ext_<id>_* | Stomp story flags by accident |
Pin runtime_min | Rely on undocumented private methods |
Failure modes (short)
Missing file, bad GDScript, missing vn_extension_setup, duplicate id, or runtime_min too new → that entry is skipped; siblings still load. Details: extensions API.
Related
extensions (game.json) · Screens · Save, Back and story end · Build and hand off · game.json map