Skip to content

johnsusek/SwiftGodotBuilder

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SwiftGodotBuilder

Declarative Godot development.

A SwiftUI-style library for building Godot games and apps using SwiftGodot. Integrates with LDtk and Aseprite.


đź“• API Documentation



Quick Start

CLI

  1. Download and unzip the CLI from the latest release

  2. Create a minimal .swift file:

import SwiftGodotBuilder

struct HelloWorld: View {
  var body: some View {
    Label$().text("Hello from SwiftGodotBuilder!")
  }
}
  1. Run it:
./swiftgodotbuilder HelloWorld.swift

The CLI scaffolds a temporary project, builds your code, and launches Godot.

Library

Add SwiftGodotBuilder to your Package.swift:

dependencies: [
  .package(url: "https://github.com/johnsusek/SwiftGodotBuilder", branch: "main")
],
targets: [
  .target(name: "YourTarget", dependencies: ["SwiftGodotBuilder"])
]

Then import and use:

import SwiftGodot
import SwiftGodotBuilder

@Godot
final class Game: Node2D {
  override func _ready() {
    addChild(node: GameView().toNode())
  }
}

struct GameView: View {
  var body: some View {
    Node2D$ {
      Label$().text("Hello World")
    }
  }
}

Core Features

Builder Syntax

// $ syntax - shorthand for GNode<T>
Sprite2D$()
CharacterBody2D$()
Label$()

// With children
Node2D$ {
  Sprite2D$()
  CollisionShape2D$()
}

// Named nodes
CharacterBody2D$("Player") {
  Sprite2D$()
}

// Custom @Godot class
GNode<CustomNode>()

Properties & Configuration

// Dynamic member lookup - set any property
Sprite2D$()
  .position([100, 200])
  .scale([2, 2])
  .rotation(45)
  .modulate(.red)
  .zIndex(10)

// Configure closure for imperative setup
RichTextLabel$().configure { label in
  label.pushColor(.cyan)
  label.appendText("Colored ")
  label.pop()
  label.appendText("text")
}

Explanation

The builder syntax is not magic, it's simply shorthand for addChild:

SwiftGodotBuilder SwiftGodot
Node2D$ {
Label$().text("Hello")
}

let node = Node2D()
let label = Label()
label.text = "Hello"
node.addChild(node: label)

Resource Loading

// Load into property
Sprite2D$()
  .res(\.texture, "player.png")
  .res(\.material, "shader_material.tres")

// Custom resource loading
Sprite2D$()
  .withResource("shader.gdshader", as: Shader.self) { node, shader in
    let material = ShaderMaterial()
    material.shader = shader
    node.material = material
  }

Signal Connections

// No arguments
Button$()
  .onSignal(\.pressed) { node in
    print("Pressed!")
  }

// With arguments
Area2D$()
  .onSignal(\.bodyEntered) { node, body in
    print("Body entered: \(body)")
  }

// Multiple arguments
Area2D$()
  .onSignal(\.bodyShapeEntered) { node, bodyRid, body, bodyShapeIndex, localShapeIndex in
    // Handle collision
  }

Process Hooks

Node2D$()
  .onReady { node in
    print("Node ready!")
  }
  .onProcess { node, delta in
    node.position.x += 100 * Float(delta)
  }
  .onPhysicsProcess { node, delta in
    // Physics updates
  }

Node References

Capture references to nodes for later use.

// On GNode - captures the node when ready
@State var playerNode: CharacterBody2D?

CharacterBody2D$()
  .ref($playerNode)

// On View - captures the root node produced by the component
@State var cameraNode: Camera2D?

CameraView()
  .ref($cameraNode)

Custom Components

Create reusable components with slots.

// Component with a content slot
struct LabeledCell<Content: View>: View {
  let label: String
  let content: Content

  init(_ label: String, @ViewBuilder content: () -> Content) {
    self.label = label
    self.content = content()
  }

  var body: some View {
    VBoxContainer$ {
      Control$ { content }.minSize([64, 64])
      Label$().text(label).horizontalAlignment(.center)
    }
  }
}

// Single child
LabeledCell("Health") {
  ProgressBar$().value(80)
}

// Multiple children (automatically grouped)
LabeledCell("Stats") {
  Label$().text("HP: 100")
  Label$().text("MP: 50")
}

View Root Node Modifiers

Set properties on a custom View.s root node using .as().

MyCustomView()
  .as(Node2D.self)
  .scale([2, 2])
  .rotation(0.5)

Passing State to Slots

Components can expose state to their slot content via closure parameters.

// Component exposes its state to content
struct PlayerPanel<Content: View>: View {
  var player = PlayerState()
  let content: (PlayerState) -> Content

  init(@ViewBuilder content: @escaping (PlayerState) -> Content) {
    self.content = content
  }

  var body: some View {
    PanelContainer$ {
      content($player)  // Pass state to slot
    }
  }
}

// Usage - slot content can bind to exposed state
PlayerPanel { player in
  Label$().text(player.health) { "HP: \($0)" }
  Label$().text(player.name)
}

Reactive Data

State Management

struct PlayerView: View {
  @State var health: Int = 100
  @State var position: Vector2 = .zero

  var body: some View {
    CharacterBody2D$ {
      Sprite2D$()
      ProgressBar$()
        .value($health)  // One-way binding
    }
    .position($position)  // Bind to property
    .onProcess { node, delta in
      health -= 1  // Modify state
    }
  }
}

State Binding Patterns

// One-way bind to property
ProgressBar$().value($health)

// Bind with formatter
Label$().text($score) { "Score: \($0)" }

// Bind to sub-property
Sprite2D$().bind(\.x, to: $position, \.x)

// Multi-state binding
Label$().bind(\.text, to: $health, $maxHealth) { "\($0)/\($1)" }

// Two-way bindings (form controls)
LineEdit$().text($username)
Slider$().value($volume)
CheckBox$().pressed($isEnabled)
OptionButton$().selected($selectedIndex)

// Sync non-reactive properties (polls via onProcess, only sets if changed)
Node2D$().sync(\.visible) { state.isSelected }
Sprite2D$().sync(\.modulate) { actor.isDying ? .red : .white }

Dynamic Views

ForEach

// ForEach - dynamic lists
struct InventoryView: View {
  @State var items: [Item] = []

  var body: some View {
    VBoxContainer$ {
      ForEach($items) { item in
        HBoxContainer$ {
          Label$().text(item.wrappedValue.name)
          Button$().text("X").onSignal(\.pressed) { _ in
            items.removeAll { $0.id == item.wrappedValue.id }
          }
        }
      }
    }
  }
}

If/Else

// If - conditional rendering
struct MenuView: View {
  @State var showSettings = false

  var body: some View {
    VBoxContainer$ {
      If($showSettings) {
        Label$().text("Settings")
      }
      .Else {
        Label$().text("Main Menu")
      }
    }
  }
}

// If modes
If($condition) { /* ... */ }                 // .hide (default) - toggle visible
If($condition) { /* ... */ }.mode(.remove)   // addChild/removeChild
If($condition) { /* ... */ }.mode(.destroy)  // queueFree/rebuild

Switch/Case

// Switch/Case - multi-way branching
enum Page { case mainMenu, levelSelect, settings }

struct GameView: View {
  @State var currentPage: Page = .mainMenu

  var body: some View {
    VBoxContainer$ {
      Switch($currentPage) {
        Case(.mainMenu) {
          Label$().text("Main Menu")
          Button$().text("Start").onSignal(\.pressed) { _ in
            currentPage = .levelSelect
          }
        }
        Case(.levelSelect) {
          Label$().text("Level Select")
          Button$().text("Back").onSignal(\.pressed) { _ in
            currentPage = .mainMenu
          }
        }
        Case(.settings) {
          Label$().text("Settings")
        }
      }
      .default {
        Label$().text("Unknown page")
      }
    }
  }
}

Computed Bindings

@State var score = 0
@State var health = 80
@State var maxHealth = 100

// Single state with transform
Label$().text($score) { "Score: \($0)" }

// Conditional based on state
If($score.computed { $0 > 1000 }) {
  Label$().text("New High Score!").modulate(.yellow)
}

// Multi-state binding with transform
Label$().bind(\.text, to: $health, $maxHealth) { "\($0)/\($1)" }

Watchers

// Watch and react to state changes
Node2D$().watch($health) { node, health in
  node.modulate = health < 20 ? .red : .white
}

ReactiveDebug

Debug utility to detect watchers firing too frequently. Useful for catching:

  • watchAny() on objects with per-frame position updates
  • Computed properties that depend on timers/positions when they shouldn't
// Enable at startup
ReactiveDebug.isEnabled = true
ReactiveDebug.warningThreshold = 30 // warn if >30 updates/sec

// Logs warnings like:
// ⚠️ [ReactiveDebug] Hot observable: 60.0/sec - EnemyState.*any*
// (This means watchAny is firing every frame - probably watching position
//  when you only care about isDying. Use a specific watch instead.)

// Print summary of all tracked state
ReactiveDebug.printSummary()

Store

struct GameState {
  var health: Int = 100
  var score: Int = 0
}

enum GameEvent {
  case takeDamage(Int)
  case addScore(Int)
}

func gameReducer(state: inout GameState, event: GameEvent) {
  switch event {
  case .takeDamage(let amount):
    state.health = max(0, state.health - amount)
  case .addScore(let points):
    state.score += points
  }
}

let store = Store(initialState: GameState(), reducer: gameReducer)

// Use in views
ProgressBar$().value(store.state(\.health))
Label$().text(store.state(\.score)) { "Score: \($0)" }

// Send events
store.commit(.takeDamage(10))
store.commit(.addScore(100))

// With middleware for logging/side effects
let store = Store(
  initialState: GameState(),
  reducer: gameReducer,
  middleware: [.logging(name: "Game")]
)

// Custom middleware
let analytics = Middleware<GameState, GameEvent> { event, state, dispatch in
  print("Event: \(event)")
}

EventBus

Modify parent state from children by emitting events instead of using callbacks.

enum GameEvent {
  case playerDied
  case scoreChanged(Int)
  case itemCollected(String)
}

// Subscribe via modifier
Node2D$()
  .onEvent(GameEvent.self) { node, event in
    switch event {
    case .playerDied: print("Game Over")
    case .scoreChanged(let score): print("Score: \(score)")
    case .itemCollected(let item): print("Got: \(item)")
    }
  }

// Subscribe with filter
Node2D$()
  .onEvent(GameEvent.self, match: { event in
    if case .scoreChanged = event { return true }
    return false
  }) { node, event in
    // Handle only score changes
  }

// Publish via ServiceLocator
let bus = ServiceLocator.resolve(GameEvent.self)
bus.publish(.scoreChanged(100))

// Or use EmittableEvent protocol
enum GameEvent: EmittableEvent {
  case playerDied
}
GameEvent.playerDied.emit()

UI

Layout

Shorthand modifiers for common layout properties.

// Anchor/offset presets (non-container parents)
Control$()
  .anchors(.center)
  .offsets(.topRight)
  .anchorsAndOffsets(.fullRect, margin: 10)
  .anchor(top: 0, right: 1, bottom: 1, left: 0)
  .offset(top: 12, right: -12, bottom: -12, left: 12)

// Container size flags (when inside a container)
Button$()
  .sizeH(.expandFill) // Horizontal
  .sizeV(.shrinkCenter) // Vertical
  .size(.expandFill, .shrinkCenter) // Both separate
  .size(.expandFill)  // Both same

Themes

let theme = Theme([
  "Label": [
    "colors": ["fontColor": Color.white],
    "fontSizes": ["fontSize": 16]
  ]
])

VBoxContainer$ {
  Label$()
    .text("This label uses a custom theme")
}
.theme(theme)

StyleBox

PanelContainer$ {
  Label$().text("Styled Panel")
}
.panelStyle(
  StyleBoxFlat$()
    .bgColor(.black.withAlpha(0.9))
    .borderColor(.cyan)
    .borderWidth(2)
    .cornerRadius(8)
    .shadowColor(.black.withAlpha(0.5))
    .shadowSize(12)
)

Builders

Declarative builders for controls with parent-child relationships.

ItemList

ItemList$ {
  ListItem("Apple")
  ListItem("Banana")
  ListItem("Cherry", disabled: true)
}
.onItemSelected { index in print("Selected: \(index)") }
.onItemActivated { index in print("Activated: \(index)") }

OptionButton

OptionButton$ {
  Option("Small", id: 0)
  Option("Medium", id: 1)
  Option("Large", id: 2)
  OptionSeparator()
  Option("Custom...", id: 99)
}
.onItemSelected { id in print("Selected: \(id)") }

Tabs

// TabBar only (no content)
TabBar$ {
  Tab("General")
  Tab("Audio")
  Tab("Video", disabled: true)
}
.onTabChanged { index in print("Tab: \(index)") }

// TabContainer with content
TabContainer$ {
  Tab("General") { Label$().text("General settings") }
  Tab("Audio") { HSlider$().value(80) }
}
.onTabChanged { index in print("Tab: \(index)") }

Tree

Tree$ {
  TreeNode("Root") {
    TreeNode("Child 1")
    TreeNode("Child 2", editable: true) {
      TreeNode("Grandchild")
    }
  }
}
.onItemSelected { print("Selected") }
.onItemActivated { print("Double-clicked") }

MenuBar

MenuBar$ {
  Menu("File") {
    MenuItem("New", id: 0)
    MenuItem("Open...", id: 1)
    MenuSeparator()
    SubMenu("Recent") {
      MenuItem("Project1.swift")
      MenuItem("Project2.swift")
    }
    MenuSeparator()
    MenuItem("Quit", id: 99)
  }
  .onItemPressed { id in print("File menu: \(id)") }

  Menu("Edit") {
    MenuItem("Undo", id: 0)
    MenuCheckItem("Auto-save", checked: true)
    MenuRadioItem("Tab size: 2", checked: true)
    MenuRadioItem("Tab size: 4")
  }
  .onItemPressed { id in print("Edit menu: \(id)") }
}

GraphEdit

@State var graphEdit: GraphEdit?

GraphEdit$ {
  GraphNode$(name: "add", title: "Add") {
    Slot("A", left: Port(.green, type: Float.self))
    Slot("B", left: Port(.green, type: Float.self))
    // Custom content in slots
    Slot(right: Port(.green, type: Float.self)) {
      Label$().text("Result")
    }
  }
  .positionOffset([100, 100])

  GraphNode$(name: "output", title: "Output") {
    Slot("Value", left: Port(.green, type: Float.self))
  }
  .positionOffset([300, 100])
}
.ref($graphEdit)
.onSignal(\.connectionRequest) { fromNode, fromPort, toNode, toPort in
  graphEdit?.connectNode(fromNode: fromNode, fromPort: Int32(fromPort), toNode: toNode, toPort: Int32(toPort))
}
.onSignal(\.disconnectionRequest) { fromNode, fromPort, toNode, toPort in
  graphEdit?.disconnectNode(fromNode: fromNode, fromPort: Int32(fromPort), toNode: toNode, toPort: Int32(toPort))
}

Misc

Context Menus

Label$().text("Right-click me")
  .contextMenu {
    MenuItem("Cut", id: 0)
    MenuItem("Copy", id: 1)
    MenuItem("Paste", id: 2)
    MenuSeparator()
    MenuItem("Delete", id: 10)
  } onItemPressed: { id in
    print("Context action: \(id)")
  }

RichTextLabel

RichTextLabel$ {
  Bold("Important: ")
  "Normal text "
  Colored(.red, "Warning!")
  Newline()
  Italic {
    "Nested "
    Bold("formatting")
  }
  Paragraph {
    FontSize(16, "Large text")
  }
  Link("https://example.com", "Click here")
}

Elements: Text, Bold, Italic, Underline, Strikethrough, Colored, FontSize, Link, Newline, Paragraph

ColorPicker

ColorPicker$ {
  Preset(.red)
  Preset(.green)
  Preset(.blue)
  Preset(hex: "#FF6600")
  Preset(r: 128, g: 0, b: 255)
}
.onColorChanged { color in print("Color: \(color)") }

// Static presets
ColorPicker$ {
  Preset.red
  Preset.orange
  Preset.yellow
  Preset.green
  Preset.blue
  Preset.purple
}

Dialogs

Dialogs must be shown explicitly via popup(). Use .ref() to capture a reference.

@State var saveDialog: AcceptDialog?

Button$().text("Save").onSignal(\.pressed) { _ in
  saveDialog?.popupCentered()
}

AcceptDialog$ {
  DialogButton("Save", action: "save")
  DialogButton("Don't Save", action: "discard")
  CancelButton()
}
.ref($saveDialog)
.title("Unsaved Changes")
.dialogText("Save before closing?")
.onConfirmed { print("OK") }
.onCustomAction { action in print(action) }

// FileDialog
@State var fileDialog: FileDialog?

FileDialog$()
  .ref($fileDialog)
  .filter("Images", "*.png,*.jpg")
  .filter("All Files", "*")
  .onFileSelected { path in print(path) }

// Show methods: popup(), popupCentered(), popupCenteredRatio(0.5)

Wrappers

Wrapper components that add behavior to child content.

// Inset from edges
SafeArea(top: 20, right: 10, bottom: 20, left: 10) {
  Label$().text("Content with margins")
}

// Center in parent
Centered {
  Label$().text("Centered")
}

Node Modifiers

Collision (2D)

Named layer helpers.

CharacterBody2D$()
  .collisionLayer(.alpha)
  .collisionMask([.beta, .gamma])

// Available layers: .alpha, .beta, .gamma, .delta, .epsilon, .zeta, .eta, .theta,
// .iota, .kappa, .lambda, .mu, .nu, .xi, .omicron, .pi, .rho, .sigma, .tau,
// .upsilon, .phi, .chi, .psi, .omega

// Combine layers
CharacterBody2D$()
  .collisionMask([.alpha, .beta])

// Debug border visualization for collision shapes
CollisionShape2D$()
  .shape(RectangleShape2D(w: 32, h: 32))
  .debugBorder(color: .red, width: 2)

Groups

Node2D$()
  .group("enemies")
  .group("damageable", persistent: true)
  .groups(["enemies", "damageable"])

Scene Instancing

Node2D$()
  .fromScene("enemy.tscn") { child in
    // Configure instanced scene
  }

Animation

Tweens

// One-shot tween
btn.tween(.scale([1.1, 1.1]), duration: 0.1)
   .ease(.out).trans(.quad)

// Fade out and remove
enemy.tween(.alpha(0.0), duration: 0.3)
   .onFinished { enemy.queueFree() }

// Managed tween (kills previous)
@State var currentTween: TweenHandle?

currentTween = btn.tween(.scale([1.1, 1.1]), duration: 0.1, killing: currentTween)
   .trans(.quad).ease(.out)

Sequences

// Bounce effect
btn.tween { seq in
  seq.to(.scale([1.0, 0.8]), duration: 0.05)
     .trans(.quad).ease(.out)
     .to(.scale([1.0, 1.15]), duration: 0.08)
     .trans(.quad).ease(.out)
     .to(.scale([1.0, 1.0]), duration: 0.12)
     .trans(.bounce).ease(.out)
}

// Looping pulse
icon.tween { seq in
  seq.to(.scale([1.05, 1.05]), duration: 0.5)
     .trans(.sine).ease(.inOut)
     .to(.scale([1.0, 1.0]), duration: 0.5)
     .trans(.sine).ease(.inOut)
}
.loop()

// Loop specific number of times
node.tween { seq in
  seq.to(.rotation(Float.pi * 2), duration: 1.0)
}
.loop(3)

Anim Properties

  • Scale: .scale(Vector2), .scaleX(Float), .scaleY(Float)
  • Position: .position(Vector2), .positionX(Float), .positionY(Float), .globalPosition(Vector2)
  • Rotation: .rotation(Float), .rotationDegrees(Float)
  • Color: .modulate(Color), .alpha(Float), .selfModulate(Color), .selfAlpha(Float)
  • Size: .size(Vector2), .minSize(Vector2), .pivotOffset(Vector2)
  • Other: .skew(Float), .volumeDb(Float), .pitchScale(Float)
  • Custom: .custom(property: String, value: Variant)

Reactive Tweens

Animate properties in response to state changes.

// Toggle animation - animate between two values based on bool state
@State var isHovered = false

Button$()
  .tweenToggle($isHovered, Anim.Scale.self,
               whenTrue: [1.1, 1.1], whenFalse: [1.0, 1.0],
               duration: 0.1)
  .onSignal(\.mouseEntered) { _ in isHovered = true }
  .onSignal(\.mouseExited) { _ in isHovered = false }

// Conditional animation - run different animations based on state value
@State var selectedTab = 0

Button$()
  .tweenWhen($selectedTab, equals: 0) { btn in
    btn.tween(.scale([1.1, 1.1]), duration: 0.1).ease(.out)
  } otherwise: { btn in
    btn.tween(.scale([1.0, 1.0]), duration: 0.1).ease(.out)
  }

// On change - custom handler for any state change
@State var health = 100

ProgressBar$()
  .tweenOnChange($health) { bar, newHealth in
    bar.tween(.scaleX(Float(newHealth) / 100.0), duration: 0.2).ease(.out)
  }

Actors

Composable 2D entity system for players, enemies, and NPCs. Supports platformers, top-down, twin-stick shooters, and grid-based games.

// Basic enemy with physics and defense
Actor { state in
  AseSprite$(path: "Skeleton")
}
.collision { _ in CollisionShape2D$().shape(RectangleShape2D(w: 16, h: 16)) }
.hurtbox { _ in CollisionShape2D$().shape(RectangleShape2D(w: 14, h: 14)) }
.physics(.init(speed: 40, gravity: 400))
.defense(.init(maxHealth: 3))

// Player with attacks
Actor { state in
  AseSprite$(path: "Hero")
    .scale(state.facingScale) // flip based on direction
  Camera2D$()
}
.collision { _ in CollisionShape2D$().shape(RectangleShape2D(w: 16, h: 24)) }
.hurtbox { _ in CollisionShape2D$().shape(RectangleShape2D(w: 14, h: 22)) }
.hitbox { _, _ in CollisionShape2D$().shape(RectangleShape2D(w: 24, h: 16)) }
.physics(.init(speed: 80, jumpSpeed: 150))
.defense(.init(maxHealth: 5, invincibilityDuration: 1.0))
.attacks([.init(melee: .init(damage: 1, knockback: 80))])
.isPlayer()

// Pre-build state
var state = ActorState()
Actor(state) { ... }

Actor Modifiers

Appending modifiers adds rich capabilities.

Actor { ... }
.collision { _ in ... }    // Terrain collision shape
.hurtbox { _ in ... }      // Can receive damage
.hitbox { _, _ in ... }    // Can deal damage
.targetbox { _ in ... }    // Target scanning (auto-enables targeting)
.interaction { _ in ... }  // NPC interaction zone
.collector { _ in ... }    // Item pickup area
.physics(config)           // Movement/gravity
.defense(config)           // Health/invincibility
.attacks([weapons])        // Weapon configs
.isPlayer()                // Won't delete on death
.behavior(initial: "state") { ... } // AI state machine
.dialog { state, dialogState in ... } // NPC dialog
.onHurt { actor, damage, knockback in ... } // Custom damage handling
.onHit { actor, targetId, damage in ... }   // When hitting a target
.onDeath { actor in ... }                  // Death callback
.onAcquiredTarget { actor, target in ... } // Target acquired
.onLostAllTargets { actor in ... }         // Lost all targets

Movement Models

Actors support three movement models via .physics() config:

// Platformer (default) - CharacterBody2D with gravity, jumping, wall sliding
Actor { ... }
  .physics(.platformer(speed: 60, gravity: 800, jumpSpeed: 130))

// Top-down - Physics-based, no gravity, 8-way facing
Actor { ... }
  .physics(.topDown(speed: 80, facingAxes: .eightWay))

// Velocity - Simple position updates, optional acceleration
Actor { ... }
  .physics(.velocity(speed: 100, acceleration: 500, deceleration: 300))

// Grid - Tile-by-tile movement for roguelikes
Actor { ... }
  .physics(.grid(tileSize: [16, 16], moveDuration: 0.1, facingAxes: .fourWay))

Facing Directions

Actors support 8-way facing: up, down, left, right, upLeft, upRight, downLeft, downRight.

// Configure which directions are allowed
.physics(.init(facingAxes: .horizontal)) // left/right only (platformers)
.physics(.init(facingAxes: .fourWay))    // cardinal directions
.physics(.init(facingAxes: .eightWay))   // all 8 directions

// Access facing
state.facing           // Current facing direction
state.facing.vector    // Unit vector for direction
state.facing.angle     // Rotation in radians
state.facing.isLeft    // True for left/upLeft/downLeft
state.facingScale      // [-1, 1] or [1, 1] for sprite flipping

Collision Layers

Each role modifier has default layers that can be overridden inline:

// Default layers (used if not specified)
.collision { _ in CollisionShape2D$() }  // layer: .beta, mask: .alpha
.hurtbox { _ in CollisionShape2D$() }    // layer: .theta, mask: .kappa
.hitbox { _, _ in CollisionShape2D$() }  // layer: .delta, mask: .iota

// Override per-role
.collision(layer: .gamma, mask: .alpha) { _ in CollisionShape2D$() }
.hurtbox(layer: .iota, mask: .delta) { _ in CollisionShape2D$() }
.hitbox(layer: .kappa, mask: [.theta, .iota]) { _, _ in CollisionShape2D$() }

Defaults are defined in ActorLayers and can be referenced directly:

.collision(layer: ActorLayers.bodyLayer, mask: [.alpha, .gamma]) { ... }

Combat Callbacks

Handle combat events with custom game logic. Events still fire for particles/sound.

Actor { state in ... }
  .onHurt { actor, damage, knockback in
    // Replaces default damage - call takeDamage manually if needed
    let reducedDamage = max(1, damage - playerArmor)
    actor.takeDamage(reducedDamage, knockback: knockback)
  }
  .onHit { actor, targetId, damage in
    comboCounter += 1
    score += damage * 10
  }
  .onDeath { actor in
    GameEvent.playerDied.emit()
  }
  .onAcquiredTarget { actor, target in
    showTargetIndicator = true
  }
  .onLostAllTargets { _ in
    showTargetIndicator = false
  }

Pickups

Collectible items with typed data.

enum Item {
  case coin(value: Int)
  case health(amount: Int)
}

Pickup(.health(amount: 10)) {
  AseSprite$(path: "Items").autoplay("heart")
  CollisionShape2D$().shape(CircleShape2D(radius: 6))
} onCollected: { (item: Item, actorId) in
  if case .health(let amount) = item {
    player.heal(amount)
  }
}

// Player needs .collector() to pick up items
Actor(playerState) { ... }
  .collector { _ in CollisionShape2D$().shape(RectangleShape2D(w: 12, h: 12)) }

Emits ActorEvent.collected(actorId, item, position) for particles/sound.

Actor Behaviors

Use .behavior() to compose AI behaviors for actors.

Actor { state in
  AseSprite$(path: "Enemy")
}
.physics(.init(speed: 40))
.targetbox { _ in CollisionShape2D$().shape(CircleShape2D(radius: 100)) }
.behavior(initial: "patrol") {
  During("patrol") {
    Patrol(left: 100, right: 300)
    Shoot(cooldown: 2.0) // Concurrent with patrol
  }
  .transition(to: "chase") { $0.distanceToTarget ?? .infinity < 80 }

  During("chase") {
    Chase()
    FaceTarget()
  }
  .transition(to: "patrol") { $0.distanceToTarget ?? 0 > 150 }
  .transition(to: "attack") { $0.distanceToTarget ?? .infinity < 32 }

  During("attack") {
    Idle()
  }
  .transition(to: "chase") { !($0.weapon?.attackPhase.isAttacking ?? false) }
}

Built-in Behaviors: Patrol (reverses on walls), Chase, Charge, Shoot, JumpOnInterval, SineWave, Idle, FaceTarget

Ranged Weapons

Use ActorProjectileSpawner to handle projectile spawning from actors with ranged weapons.

Node2D$ {
  ActorProjectileSpawner() // Add once to scene

  Actor { state in
    AseSprite$(path: "Turret")
  }
  .attacks([.init(ranged: .init(damage: 1, speed: 200))])
  .behavior(initial: "shoot") {
    During("shoot") {
      Shoot(cooldown: 1.5)
    }
  }
}

Listens for ActorEvent.projectileFired and spawns projectiles with appropriate collision layers.

Actor Dialog

Add dialog capability to NPCs with .dialog {}. Requires separate .interaction {} for the interaction zone.

Actor(npcState) { state in
  AseSprite$(path: "Merchant")
}
.interaction { _ in CollisionShape2D$().shape(RectangleShape2D(w: 24, h: 32)) }
.dialog { actorState, dialogState in
  Dialog(id: "merchant") {
    Branch("main") {
      if dialogState.isFirstVisit {
        Merchant ~ "Welcome to my shop!"
      } else {
        Merchant ~ "Back for more?"
      }
    }
  }
}

Use with DialogManager (see Dialog Trees section) for automatic dialog UI handling.

Game Systems

Dialog Trees

Screenplay-style dialog trees.

// Define speakers
let Guard = Speaker("Guard")
let Merchant = Speaker("Merchant")

// Create dialogs with branches
Dialog(id: "guard") {
  Branch("main") {
    Guard ~ "Halt! The path ahead is dangerous."

    Choice("I can handle it.") {
      Guard ~ "Ha! I like your spirit!"
    }

    Choice("Any tips?") {
      Guard ~ "Watch for patterns."
      Jump("tips")  // Jump to another branch
    }

    Choice("Bye.") {
      End
    }
  }

  Branch("tips") {
    Guard ~ "Use checkpoints!"
    Guard ~ "Good luck out there."
  }
}

// Conditional choices
Choice("Pay 10 gold", when: { game.gold >= 10 }) {
  Emit("payGold", ["amount": 10])
  Merchant ~ "Thank you!"
}

// Conditional blocks - runtime evaluated
When({ game.hasKey }) {
  Guard ~ "You have the key!"
  Jump("unlocked")
}

// Per-NPC state via DialogState
func makeDialog(state: DialogState) -> DialogDefinition {
  Dialog(id: "guard") {
    Branch("main") {
      When({ state.isFirstVisit }) {
        Guard ~ "Welcome, stranger!"
      }
      When({ state.visitCount > 1 }) {
        Guard ~ "Back again?"
      }
    }
  }
}

// Create dialog state (track visit counts in your game state)
let state = DialogState(visitCount: myGameState.getVisitCount(for: "guard"))

// Run dialog
let runner = DialogRunner(dialog: myDialog)
runner.onLine = { line in print("\(line.speaker): \(line.text)") }
runner.onChoices = { choices in /* show UI */ }
runner.onEnd = { /* close dialog */ }
runner.start()
runner.advance()           // Next line
runner.selectChoice(0)     // Pick choice

// Handle Emit() events
.onEvent(DialogBusEvent.self) { _, event in
  if case .emitted(let name, let data) = event, name == "payGold" {
    player.gold -= data?["amount"] as? Int ?? 0
  }
}

DialogManager

Self-contained dialog UI that handles dialog lifecycle automatically. Add once to your scene.

Node2D$ {
  DialogManager(speakerColors: ["Guard": .blue, "Merchant": .gold])

  // NPCs with .dialog modifier trigger automatically
  Actor(guardState) { ... }
    .interaction { _ in CollisionShape2D$().shape(RectangleShape2D(w: 24, h: 32)) }
    .dialog { _, _ in guardDialog }
}

Features typewriter text effect, choice buttons, and emits DialogManagerEvent for integration:

.onEvent(DialogManagerEvent.self) { _, event in
  switch event {
  case .dialogActive(true): pauseGame()
  case .dialogActive(false): resumeGame()
  case .dialogEnded(let actorId, let dialogId): handleDialogComplete(actorId, dialogId)
  default: break
  }
}

Object Pools

ObjectPool

Generic pool for reusable Godot objects.

final class Bullet: Node2D, PooledObject {
  func onAcquire() { visible = true }
  func onRelease() { visible = false; position = .zero }
}

let pool = ObjectPool<Bullet>(factory: { Bullet() })
pool.preload(64)

if let bullet = pool.acquire() {
  bullet.position = spawnPos
  parent.addChild(node: bullet)
  // later:
  pool.release(bullet)
}

// Or use PoolLease for scoped usage
PoolLease(pool).using { bullet in
  // automatically released after closure
}

AreaPool

Pool for Area2D projectiles with velocity-based movement.

let bulletPool = AreaPool(
  preload: 30,
  speed: 300,
  lifetime: 3.0,
  bounds: (-50, 850, -50, 290)
) {
  Area2D$ {
    Sprite2D$().res(\.texture, "bullet.png")
    CollisionShape2D$().shape(CircleShape2D(radius: 4))
  }
  .collisionLayer(.beta)
  .collisionMask(.alpha)
}

// Call once when parent is in scene tree
bulletPool.start()

// Fire projectiles
bulletPool.fire(at: playerPos, direction: aimDir, parent: self)

// Update in _process
bulletPool.update(delta: delta)

TypedParticlePool

Multi-variant particle pool keyed by type.

enum ParticleType { case dust, spark, blood }

let particles = TypedParticlePool<ParticleType, CPUParticles2D>(
  keys: [.dust, .spark, .blood],
  config: .init(prewarmPerType: 5)
) { type in
  switch type {
  case .dust: return CPUParticles2D(.dust)
  case .spark: return CPUParticles2D(.sparkle)
  case .blood: return CPUParticles2D(.splatter)
  }
}

particles.setup(parent: self)
particles.spawn(type: .dust, at: position)
particles.spawn(type: .spark, at: hitPoint, scale: [2, 2])

ActorPool

Pool for reusing Actor nodes. One pool per actor type.

// Create pool with make and makeBehavior closures
let slimePool = ActorPool(
  prewarm: 10,
  max: 50,
  make: {
    let state = ActorState()
    let node = SlimeActor(state: state).toNode() as! CharacterBody2D
    return (node, state)
  },
  makeBehavior: { AnyBehaviorMachine(SlimeBehavior()) }
)

slimePool.setup(parent: levelNode)

// Spawn actors
slimePool.spawn(at: spawnPoint, facing: .left)

// Release via ActorEvent.died listener
.onEvent(ActorEvent.self) { _, event in
  if case let .died(actorId) = event {
    slimePool.release(actorId: actorId)
  }
}

Pooled actors skip queueFree() on death - the pool handles node lifecycle.

Particle Effects

// Built-in presets: .explosion, .sparkle, .dust, .splatter, .smoke
CPUParticles2D$()
  .config(.explosion)
  .oneShot(true)
  .emitting(true)

// Modify presets
CPUParticles2D$()
  .config(.explosion.withColor(.red).withAmount(30))

// Custom config
CPUParticles2D$()
  .config(ParticleConfig(
    amount: 20,
    lifetime: 0.8,
    explosiveness: 1.0,
    direction: [0, -1],
    spread: 180,
    initialVelocityMin: 100,
    initialVelocityMax: 200,
    gravity: [0, 400],
    color: .orange
  ))

// Direct initialization
let particles = CPUParticles2D(.dust)
particles.emitting = true

Spawners

// Damage numbers, popups
FloatingTextSpawner(ActorEvent.self) { event in
  if case let .damaged(_, amount, position) = event {
    return (text: "\(amount)", position: position, color: .red)
  }
  return nil
}

// Spawn nodes in response to events
NodeSpawner(GameEvent.self) { event in
  if case let .itemSpawned(position) = event {
    return Sprite2D$().position(position).toNode()
  }
  return nil
} resetWhen: { event in
  if case .gameReset = event { return true }
  return false
}

Input Actions

// Define actions
Actions {
  Action("jump") {
    Key(.space)
    JoyButton(.a, device: 0)
  }

  Action("shoot") {
    MouseButton(1)
    Key(.leftCtrl)
  }

  // Analog axes
  ActionRecipes.axisUD(
    namePrefix: "move",
    device: 0,
    axis: .leftY,
    dz: 0.2,
    keyDown: .s,
    keyUp: .w
  )

  ActionRecipes.axisLR(
    namePrefix: "move",
    device: 0,
    axis: .leftX,
    dz: 0.2,
    keyLeft: .a,
    keyRight: .d
  )
}
.install(clearExisting: true)

// Runtime polling
if Action("jump").isJustPressed {
  player.jump()
}

if Action("shoot").isPressed {
  player.shoot(Action("shoot").strength)
}

let horizontal = RuntimeAction.axis(negative: "move_left", positive: "move_right")
let movement = RuntimeAction.vector(
  negativeX: "move_left",
  positiveX: "move_right",
  negativeY: "move_up",
  positiveY: "move_down"
)

Asset Integrations

LDtk

Complete workflow for loading LDtk levels and bridging enums.

// Define type-safe enums matching LDtk enum values
enum Item: String, LDEnum {
  case knife = "Knife"
  case boots = "Boots"
  case potion = "Potion"
}

struct GameView: View {
  let project: LDProject
  @State var inventory: [Item] = []
  @State var spawnPosition: Vector2 = .zero

  var body: some View {
    Node2D$ {
      LDLevelView(project, level: "Level_0")
        // Spawn nodes for entities
        .onEntitySpawn("Player") { entity, level, project in
          // Collision layers from LDtk
          let wallLayer = project.collisionLayer(for: "walls", in: level)
          // Typed fields
          let startItems: [Item] = entity.field("starting_items")?.asEnumArray() ?? []
          inventory.append(contentsOf: startItems)

          return CharacterBody2D$ {
            Sprite2D$()
              .res(\.texture, "player.png")
              .anchor([16, 22], within: entity.size, pivot: entity.pivotVector)
            CollisionShape2D$()
              .shape(RectangleShape2D(w: 16, h: 22))
          }
          .position(entity.positionCenter)
          .collisionMask(wallLayer)
        }
        // Side effects only (no node spawned)
        .onEntity("Enemy") { entity, _, _ in
          enemySpawnPosition = entity.positionCenter
        }
        // Post-process all spawned entities
        .onSpawned { entity, node in
          node.addChild(node: Label$().text(entity.identifier).toNode())
        }
    }
  }
}

// Usage
let project = try! LDProject.load(path: "res://game.ldtk")
addChild(node: GameView(project: project).toNode())

Reactive Level Switching

// Level changes automatically rebuild the view
LDLevelView(project, level: $state.currentLevelId)
  .onEntitySpawn("Player") { entity, level, project in
    PlayerView(entity: entity)
  }

LDtk Field Accessors

All LDtk field types are supported:

// Single values
entity.field("health")?.asInt() -> Int?
entity.field("speed")?.asDouble() -> Double?
entity.field("speed")?.asFloat() -> Float?
entity.field("locked")?.asBool() -> Bool?
entity.field("name")?.asString() -> String?
entity.field("tint")?.asColor() -> Color?
entity.field("destination")?.asPoint() -> LDPoint?
entity.field("spawn_pos")?.asVector2(gridSize: 16) -> Vector2?
entity.field("target")?.asEntityRef() -> LDEntityRef?
entity.field("item_type")?.asEnum<Item>() -> Item?

// Arrays
entity.field("scores")?.asIntArray() -> [Int]?
entity.field("distances")?.asDoubleArray() -> [Double]?
entity.field("distances")?.asFloatArray() -> [Float]?
entity.field("flags")?.asBoolArray() -> [Bool]?
entity.field("tags")?.asStringArray() -> [String]?
entity.field("waypoints")?.asPointArray() -> [LDPoint]?
entity.field("patrol")?.asVector2Array(gridSize: 16) -> [Vector2]?
entity.field("palette")?.asColorArray() -> [Color]?
entity.field("targets")?.asEntityRefArray() -> [LDEntityRef]?
entity.field("loot")?.asEnumArray<Item>() -> [Item]?
entity.field("values")?.asArray() -> [LDFieldValue]?  // Raw array

LDtk Collision Helper

// Get physics layer bit for IntGrid group name
let wallLayer = project.collisionLayer(for: "walls", in: level)
let platformLayer = project.collisionLayer(for: "platforms", in: level)

CharacterBody2D$()
  .collisionMask(wallLayer | platformLayer)

Tile Layer Handlers

Override default tile layer rendering with custom handlers.

LDLevelView(project, level: "Level_0")
  .onTileLayerSpawn("Breakable") { layer, level, project in
    LDBreakableTerrainView(layer: layer, project: project)
      .terrainCollisionLayer(.terrain)
      .detectionMask(.combat)
      .onTileDestroyed { position in
        GameEvent.terrainDestroyed(position: position).emit()
      }
  }

LDIntGridZonesView

Build Area2D collision zones from IntGrid values with identifiers.

LDIntGridZonesView(layer: hazardLayer, project: project)
  .collisionLayer(.hazard)
  .collisionMask(.player)
  .onZoneEnter { zone, body in
    if zone.identifier == "damage" {
      GameEvent.playerHit(damage: 1).emit()
    }
  }
  .onZoneExit { zone, body in
    // Handle exit
  }

LDBreakableTerrainView

Tile layer where tiles can be destroyed by collision.

LDBreakableTerrainView(layer: breakableLayer, project: project)
  .terrainCollisionLayer(.terrain)
  .detectionLayer([])
  .detectionMask(.combat)
  .onTileDestroyed { position in
    GameEvent.terrainDestroyed(position: position).emit()
  }

LDTileFieldView

Renders sprites from LDtk tile fields with automatic tiling.

// From tile counts
if let tile = entity.field("sprite")?.asTile() {
  LDTileFieldView(tile: tile, project: project, gridSize: 8, tileCountX: 4, tileCountY: 2)
}

// From pixel dimensions
LDTileFieldView(tile: tile, project: project, gridSize: 8, width: 32, height: 16)

AseSprite

// Load Aseprite animations
let sprite = AseSprite(
  "character.json",
  layer: "Body",
  options: .init(
    timing: .delaysGCD,
    trimming: .applyPivotOrCenter
  ),
  autoplay: "Idle"
)

// Builder pattern
AseSprite$(path: "player", layer: "Main")
  .configure { sprite in
    sprite.play(anim: "Walk")
  }

SVGSprite

Runtime SVG rendering with vertex manipulation effects.

// Basic usage
SVGSprite$()
  .path("icon.svg")
  .size(16) // Default is 32
  .colors([.red, .darkRed, .crimson]) // Per-element colors
  .stroke(.white, width: 2)

// CSS class color overrides (for SVGs with <style> blocks)
SVGSprite$()
  .path("icon.svg")
  .classColors(["cls-1": .red, "cls-2": .blue])

// Mixing effect types - chaining works fine
SVGSprite$()
  .colorCycle([.red, .orange, .yellow]) // color effect
  .inflate(amount: 3.0)                 // vertex effect

// Multiple vertex effects - use svgEffects builder
SVGSprite$()
  .path("icon.svg")
  .svgEffects {
    SVGInflate(amount: 3.0) // applied first
    SVGNoise(amount: 1.5)   // applied to inflated result
  }

// With reactive bindings
@State var meltProgress: Double = 0

SVGSprite$()
  .path("enemy.svg")
  .melt(progress: $meltProgress)  // Animate on death

Color Effects: .pulse(speed, amplitude, baseSize), .colorCycle(colors, speed), .strokeCycle(colors, speed), .dualColorCycle(fill:, stroke:)

Vertex Effects: .wobble(amount, speed), .inflate(amount, speed), .skew(amount, speed, animated), .noise(amount, speed), .ripple(amplitude, frequency, speed), .twist(amount, speed), .wave(amplitude, frequency, speed), .explode(progress, scale), .scatter(progress, scale, rotate), .melt(progress, scale, waviness)

Bfxr

Real-time synthesis of retro sound effects from .bfxr files.

// Basic sound playback
BfxrSound$("res://sounds/jump.bfxr")
  .onReady { node in
    node.playSound()
  }

// With reactive bindings
struct GameView: View {
  @State var pitch: Double = 1.0

  var body: some View {
    Node2D$ {
      BfxrSound$("res://sounds/laser.bfxr")
        .ref($laserSound)
        .frequencyStart($pitch)
    }
  }
}

SpriteSheet

Define spritesheets using a final entries dictionary.

let PlayerSprite = SpriteSheet(
  "res://player.png",
  tile: [16, 16],
  columns: 8,
  entries: [
    "idle": 0,
    "walk": 1...4,
    "run": [1...4, 8],   // fps as second element
    "hit": [[8, 9], 12]
  ]
)

Built-in Views

Layout

Spacer(16)      // Fixed height spacer
SpacerV()       // Vertical expand-fill
SpacerH()       // Horizontal expand-fill

ColorBox

Polygon2D-based colored rectangle (like ColorRect for outside the UI).

ColorBox$([100, 50])
  .color(.red)
  .position([200, 300])

Extensions & Utilities

Vector2 Extensions

let pos: Vector2 = [100, 200]
let doubled = pos * 2
let scaled = pos * 1.5

Color Extensions

// Create colors with alpha
let semiTransparent = Color.black.withAlpha(0.9)
let glowColor = Color.cyan.withAlpha(0.5)

### Shape Extensions

```swift
let rect = RectangleShape2D(w: 50, h: 100)
let circle = CircleShape2D(radius: 25)
let capsule = CapsuleShape2D(radius: 10, height: 50)
let segment = SegmentShape2D(a: [0, 0], b: [100, 100])
let ray = SeparationRayShape2D(length: 100)
let boundary = WorldBoundaryShape2D(normal: [0, -1], distance: 0)

Node Extensions

// Typed queries
let sprites: [Sprite2D] = node.getChildren()
let firstSprite: Sprite2D? = node.getChild()
let enemySprite: Sprite2D? = node.getNode("Enemy")

// Group queries
let enemies: [Enemy] = node.getNodes(inGroup: "enemies")

// Parent chain
let parents: [Node2D] = node.getParents()

// Metadata queries (recursive)
let spawns: [Node2D] = root.queryMeta(key: "type", value: "spawn")
let valuable: [Node2D] = root.queryMeta(key: "value", value: 100)
let markers: [Node2D] = root.queryMetaKey("marker")

// Get typed metadata
let coinValue: Int? = node.getMetaValue("coin_value")

Engine Extensions

if let tree = Engine.getSceneTree() {
  // ...
}

Engine.onNextFrame {
  print("Next frame!")
}

Engine.onNextPhysicsFrame {
  print("Next physics frame!")
}

MsgLog

Thread-safe logging singleton.

MsgLog.shared.debug("Debug message")
MsgLog.shared.info("Info message")
MsgLog.shared.warn("Warning")
MsgLog.shared.error("Error")

// Set minimum level
MsgLog.shared.minLevel = .warn

// Custom sink
MsgLog.shared.sink = { level, message in
  print("[\(level)] \(message)")
}

UserSettings

Persistable audio/display settings.

let settings = UserSettings()  // Auto-loads from disk
settings.masterVolume, settings.sfxVolume, settings.musicVolume
settings.masterVolumeDisplay  // "70%"

AudioManager

Syncs volume settings with AudioServer.

AudioManager(settings: $settings) {
  BfxrSound$().bfxrPath("sounds/Jump.bfxr")
}

Property Wrappers

For imperative @Godot classes - add bindProps() in _ready() to activate property wrappers.

@Godot
final class Player: CharacterBody2D {
  @Child("Sprite") var sprite: Sprite2D?
  @Child("Health", deep: true) var healthBar: ProgressBar?
  @Children var buttons: [Button]
  @Ancestor var level: Level?
  @Sibling("AudioPlayer") var audio: AudioStreamPlayer?
  @Autoload("GameManager") var gameManager: GameManager?
  @Group("enemies") var enemies: [Enemy]
  @Service var events: EventBus<GameEvent>?
  @Prefs("musicVolume", default: 0.5) var volume: Double

  @OnSignal("StartButton", \Button.pressed)
  func onStartPressed(_ sender: Button) {
    print("Started!")
  }

  override func _ready() {
    bindProps()

    sprite?.visible = true
    enemies.forEach { print($0) }

    // Refresh group query
    let currentEnemies = $enemies()
  }
}