Skip to content

Custom scenes & assets

A UI can be built entirely from code, one widget at a time. For layouts of any size it is usually better to design screens in the Godot editor (a drag-and-drop UI designer with live theme preview) and have your code drive them.

The workflow:

  1. Design a screen in the editor and save it as a .tscn file.
  2. Ship it with your app: as a loose file during development, packed for release.
  3. Drive it from code by node name.

Examples on this page use the Bun SDK. Python is identical with snake_case names; the language guides show the equivalent messages for compiled languages.

Designing in the editor

Create a normal Godot project and lay out screens with Control nodes. Two constraints apply to scenes used with guido:

  • Use UI nodes. The runtime includes all Controls, containers, Timer and AnimationPlayer, but 3D and physics nodes are compiled out. A scene that references a missing class will not load.
  • Do not attach scripts. A scene with a script still loads (the layout arrives complete and the script reference is ignored, with an error on stderr), but no GDScript runs. Event handling belongs in your application.

Node names are the interface between the scene and your code. Give stable names to every widget you will read, write or subscribe to. For nested widgets, enable "Access as Unique Name" in the editor (the % badge) so the node can be addressed as %SaveButton regardless of its position in the tree.

Using a scene from code

ts
const ui = await client.instantiateScene("/home/me/myapp/ui/main.tscn");
(await client.getRoot()).addChild(ui);

const save = await ui.call<GodotObject>("get_node", "%SaveButton");
const status = await ui.call<GodotObject>("get_node", "%StatusLabel");

await save.on("pressed", async () => {
  await saveDocument();
  status.set("text", "Saved.");
});

A node obtained with get_node is an ordinary object: set properties, call methods, subscribe with on. To discover structure instead of hardcoding paths, ui.queryTree() returns the instantiated tree with names, types and children.

The development loop is: edit the scene in the editor, save, restart your app. The runtime starts in under a second.

Packing scenes for release

instantiateScene with an absolute path works during development. For release, bundle the project into a pack file (.pck) and load contents by project path (res://...):

ts
await client.request("loadPack", { path: resolve("app.pck") });  // absolute path
const ui = await client.instantiateScene("res://main.tscn");

To mount at launch instead, pass args: ["--no-project", "--bridge-pack", "/abs/app.pck"].

Two ways to produce the pack:

  • Editor export. Project → Export → add a Linux preset → Export PCK/ZIP. This handles everything the editor manages, including imported images and fonts and cross-scene references. Keep texture imports on the default Lossless/Lossy compression; the "VRAM Compressed" option requires GPU codec modules the runtime does not include.
  • guido_pack.py project-dir app.pck, included in the tarball (~/.local/share/guido/guido_pack.py) and the repo (misc/scripts/). Packs a directory without the editor. Suitable for projects made of scenes, themes and fonts; use editor export when the project has imported assets.

Additional pack behavior:

  • Packs can be mounted at runtime repeatedly. loadPack with replace: true overrides files from earlier packs. Resources that were already loaded stay cached for the life of the process, so replacing a pack affects only paths not yet loaded.
  • A shipped runtime does not run loose project folders. Assets come from explicit paths or mounted packs only, so a project.godot in the user's working directory has no effect.
  • Scenes saved by the editor reference sub-scenes, themes and textures by res:// path, which resolves against mounted packs. A self-contained scene loads from a loose absolute path; a scene that references other files needs its pack mounted. uid:// warnings on stderr when loading loose editor files are harmless; the loader falls back to the path.

Loading assets

Everything below accepts a res:// path (from a mounted pack) or an absolute filesystem path.

Themes restyle every widget below the node they are set on. Eight prebuilt themes (Adwaita, Breeze, Fluent, macOS; light and dark) are included in the tarball (~/.local/share/guido/themes/) and the repo (demo-apps/themes/):

ts
const theme = await client.load("/home/me/.local/share/guido/themes/adwaita_dark.tres");
(await client.getRoot()).set("theme", theme);

Images known at build time belong in the pack. Images chosen at runtime (avatars, file previews) are loaded explicitly. PNG, JPEG, WebP and SVG are supported:

ts
const img = await client.instantiate("Image");
await img.call("load", "/home/me/avatar.png");
const tex = await client.instantiate("ImageTexture");
await tex.call("set_image", img);
avatarRect.set("texture", tex);
img.free();  // the texture keeps its own copy

Fonts load at runtime from .ttf/.otf files:

ts
const font = await client.instantiate("FontFile");
await font.call("load_dynamic_font", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf");
title.set("theme_override_fonts/font", font);

Fonts referenced by a theme or scene inside a pack load automatically.

For a scene instantiated many times (list rows, cards), load it once and instantiate per item:

ts
const row = await client.load("res://row.tscn");
for (const item of items) {
  const r = await client.instantiateScene(row);
  (await r.call<GodotObject>("get_node", "%Title")).set("text", item.title);
  list.addChild(r);
}

Path rules

Paths in messages are resolved by the runtime, not by your script:

  • res://... refers to mounted pack contents. Use it for everything you ship.
  • Absolute filesystem paths work everywhere and are required for user files.
  • Relative paths resolve against the runtime's working directory, which is rarely what you intended. Avoid them; path.resolve() / os.path.abspath() before sending.