module Lapis::Docs::C_GAMEPLAY_AND_DECLARATIVE_DSL::A_NODE_DSL

Overview

Declarative Node DSL & Lifecycle

Comprehensive guide to authoring gameplay entities using Lapis's declarative Node DSL, ClassDB registration, and engine lifecycle virtual methods.

Executive Summary & Key Topics

Topic Method / Anchor Description
Core DSL Features .topic_00_dsl_features Declarative class registration, export properties, typed signals, and in-editor tool execution.
Node Declaration Syntax .topic_01_node_declaration Declaring custom nodes inheriting from 2D, 3D, UI, or Server nodes.
Engine Lifecycle Virtual Methods .topic_02_lifecycle_methods Virtual methods invoked by the Godot scene tree during execution.
In-Editor Tool Scripts (@[Tool]) .topic_03_tool_scripts Executing Crystal code live inside the Godot Editor viewport and scene dock.
Ergonomic Node Declarations & Groups .topic_04_node_helpers Convenient macros for groups, cached node references, and unique scene access.
Context-Aware Node Reopening (Define vs. Monkeypatch Mode) .topic_05_context_aware_node_reopening Modularizing Godot node definitions across multiple files without inheritance conflicts.
Traditional GodotClass Annotation Syntax .topic_06_traditional_godot_class_syntax Authoring native Godot nodes using standard Crystal class syntax and the @[GodotClass] annotation.
Pure Crystal Domain Models & Stack-Allocated Structs .topic_07_pure_crystal_domain_models Decoupling game logic into zero-allocation stack structs for 30ms headless testing and peak cache efficiency.
Autoload Nodes & Engine Singletons (@[Autoload]) .topic_08_autoload_node_singletons Declaring persistent autoload nodes automatically registered as engine singletons and mounted to the SceneTree root.

Related Guides & Source References

Defined in:

libgodot/docs/c_gameplay_and_declarative_dsl/a_node_dsl.cr

Class Method Summary

Class Method Detail

def self.topic_00_dsl_features : Nil #

Core DSL Features: Declarative class registration, export properties, typed signals, and in-editor tool execution.

Key Topics & Information

  • Declarative syntax: node ClassName < ParentNode do ... end
  • Context-aware node reopening across files without inheritance conflict
  • Traditional class syntax via @[GodotClass] annotation
  • Pure Crystal domain models with zero GC stack-allocated structs
  • Automatic ClassDB engine registration
  • Virtual method overrides: _ready, _process, _physics_process, _enter_tree, _exit_tree
  • In-editor live execution via @[Tool] annotation
  • Declarative Autoload node singletons via @[Autoload] and typed .instance accessors

def self.topic_01_node_declaration : Nil #

Node Declaration Syntax: Declaring custom nodes inheriting from 2D, 3D, UI, or Server nodes.

Declare a custom Godot node using the node macro:

require "libgodot"

node Enemy < CharacterBody2D do
  @[Export]
  property max_health : Int32 = 100

  def _ready : Void
    Godot.print("Enemy #{name} ready!")
  end
end

Supported Parent Classes:

You can inherit from any registered Godot engine node:


def self.topic_02_lifecycle_methods : Nil #

Engine Lifecycle Virtual Methods: Virtual methods invoked by the Godot scene tree during execution.

Godot dispatches lifecycle callbacks to custom nodes:

Virtual Method Invocation Timing Typical Use Case
_enter_tree : Void When node enters active scene tree Subscribing to signals, global lookups
_ready : Void When node and all children enter tree Initialization, setting initial positions
_process(delta : Float64) : Void Every visual render frame Smooth visuals, UI updates, animations
_physics_process(delta : Float64) : Void Fixed-rate physics step (60 Hz) Movement, raycasts, collision calculations
_exit_tree : Void When node leaves active scene tree Cleanup, unregistering listeners

def self.topic_03_tool_scripts : Nil #

In-Editor Tool Scripts (@[Tool]): Executing Crystal code live inside the Godot Editor viewport and scene dock.

Annotate a class with @[Tool] to have it execute inside the Godot Editor:

@[Tool]
node LevelGenerator < Node2D do
  @[ExportToolButton("Generate Maze")]
  property generate_button : ToolButton do
    generate_level!
  end

  def generate_level! : Void
    Godot.print("Procedural generation running in Godot Editor!")
  end
end

When selected in the editor, clicking the inspector tool button executes the Crystal code live in the viewport!


def self.topic_04_node_helpers : Nil #

Ergonomic Node Declarations & Groups: Convenient macros for groups, cached node references, and unique scene access.

Lapis provides built-in macros for declarative scene setup directly inside the node block:

node Player < CharacterBody2D do
  # Declarative group membership
  group "players", "damageable"

  # Eager onready property initialized during _ready via unary ~:
  onready sprite : Sprite2D = ~"Sprite2D"
  onready health_bar : ProgressBar = ~"%HealthBar"
  onready anim = ~"AnimationPlayer".as(AnimationPlayer)

  # Lazy-cached node accessors (resolved on first access with dead-pointer safety):
  onready camera, Camera2D, "Pivot/Camera2D"
  onready? particle_fx, CPUParticles2D

  # Scene Unique Node access (%UniqueNodeName)
  unique_node hud, CanvasLayer, "PlayerHUD"
  unique_node? quest_tracker, Control
end

def self.topic_05_context_aware_node_reopening : Nil #

Context-Aware Node Reopening (Define vs Monkeypatch Mode): Modularizing Godot node definitions across multiple files without inheritance conflicts.

Lapis features a context-aware node macro that distinguishes between the initial class definition and subsequent class reopenings (monkeypatching).

In standard Crystal, declaring class Player < CharacterBody2D a second time triggers a compile-time superclass mismatch or redefinition error. Lapis inspects class resolution at compile time:

  • Initial Definition: node Player < CharacterBody2D creates the class, registers it in ClassDB, binds initial exported properties and lifecycle methods.
  • Reopening (Monkeypatch): Subsequent declarations (node Player do ... end) automatically open the class in monkeypatch mode without repeating < SuperClass.

All exported properties, signals, RPC definitions, and virtual methods from reopened blocks are dynamically merged into a single unified ClassDB table:

# File: src/entities/player.cr
node Player < CharacterBody2D do
  @[Export]
  property speed : Float32 = 250.0_f32

  def _physics_process(delta : Float64) : Void
    # Movement logic
  end
end

# File: src/entities/player_combat.cr
node Player do
  signal health_changed(current : Int32, max : Int32)

  @[Export]
  property attack_power : Int32 = 25

  def take_damage(amount : Int32) : Void
    # Combat resolution
  end
end

Dispatch methods (_godot_call_virtual, _godot_set_property, _godot_get_property) seamlessly chain to previous_def, allowing modular code splitting across feature directories without runtime overhead.


def self.topic_06_traditional_godot_class_syntax : Nil #

Traditional GodotClass Annotation Syntax: Authoring native Godot nodes using standard Crystal class syntax and the @[GodotClass] annotation.

Developers who prefer traditional object-oriented Crystal class syntax over declarative macro blocks can annotate standard classes with @[GodotClass]:

require "libgodot"

@[GodotClass]
class Player < Godot::CharacterBody2D
  @[Export]
  property speed : Float32 = 300.0_f32

  @[Export]
  property max_health : Int32 = 100

  def _ready : Void
    Godot.print("Player #{name} initialized via @[GodotClass]!")
  end

  def _physics_process(delta : Float64) : Void
    # Standard physics movement
  end
end

Automated ClassDB Synthesis:

When a class inherits from any Godot::Object subclass and carries @[GodotClass]:

  1. ClassDB Registration: Automatically generates entry with proper parent class name (CharacterBody2D).
  2. Property Reflection: Gathers all @[Export] properties and registers typed PropertyInfo entries into the Godot Inspector.
  3. Virtual Dispatch: Automatically wires _ready, _process, _physics_process, and input methods to engine ptrcalls.
  4. Zero Macro Boilerplate: Requires no node do ... end wrapper blocks, matching standard Crystal class design.

def self.topic_07_pure_crystal_domain_models : Nil #

Pure Crystal Domain Models & Stack-Allocated Structs: Decoupling game logic into zero-allocation stack structs for 30ms headless testing and peak cache efficiency.

While Godot nodes are excellent for spatial hierarchies and rendering, writing all gameplay logic directly inside engine nodes introduces native wrapper overhead, garbage collection pressure, and slow test feedback loops.

Lapis enables a high-performance Domain-Driven Design (DDD) pattern using pure Crystal stack-allocated structs:

# Zero GC allocation - lives directly in parent memory
struct CombatStats
  property health : Int32
  property max_health : Int32
  property defense : Int32
  property attack_power : Int32

  def initialize(@max_health : Int32, @defense : Int32, @attack_power : Int32)
    @health = @max_health
  end

  def alive? : Bool
    @health > 0
  end

  # Pure deterministic calculation - zero engine dependencies
  def apply_incoming_damage(raw_damage : Int32) : Int32
    mitigated = Math.max(1, raw_damage - @defense)
    @health = Math.max(0, @health - mitigated)
    mitigated
  end
end

Clean Composition inside a Godot Node:

The Godot node acts strictly as a visual renderer and spatial bridge:

node Player < CharacterBody2D do
  # Inlined directly in memory - no separate heap pointer
  @stats = CombatStats.new(max_health: 100, defense: 15, attack_power: 25)

  def take_hit(damage : Int32) : Void
    mitigated = @stats.apply_incoming_damage(damage)
    Godot.print("Player took #{mitigated} damage! Remaining: #{@stats.health}")
    queue_free unless @stats.alive?
  end
end

High-Velocity Test-Driven Development (TDD):

Because CombatStats and other domain models have no dependency on Godot's SceneTree, developers can write comprehensive headless Crystal specs:

# spec/domain/combat_stats_spec.cr
require "spec"
require "../../src/domain/combat_stats"

describe CombatStats do
  it "correctly mitigates damage by defense rating" do
    stats = CombatStats.new(max_health: 100, defense: 10, attack_power: 20)
    actual_dmg = stats.apply_incoming_damage(35)
    actual_dmg.should eq(25)
    stats.health.should eq(75)
    stats.alive?.should be_true
  end
end

Running crystal spec spec/domain/ executes hundreds of unit tests in 30 milliseconds (0.03s), without booting Godot, creating OS windows, or waiting for scene tree initialization!


def self.topic_08_autoload_node_singletons : Nil #

Autoload Nodes & Engine Singletons (@[Autoload]): Declaring persistent autoload nodes automatically registered as engine singletons and mounted to the SceneTree root.

In Godot, Autoloads are persistent nodes loaded automatically at startup, accessible globally across scenes, and optionally registered as engine singletons in Engine.has_singleton.

Lapis provides first-class declarative support for Autoload nodes via the @[Autoload] annotation or in-body autoload macro directive:

@[Autoload]
node GameManager < Node do
  property score : Int32 = 0
  property current_level : String = "world_1"

  def add_score(points : Int32) : Void
    @score += points
  end
end

Automatic Lifecycle & SceneTree Mounting:

  1. Automatic Lifecycle: When the GDExtension library initializes or the SceneTree boots, AutoloadManager instantiates every registered Autoload node.
  2. SceneTree Root Attachment: Autoloads are mounted under the root viewport (/root/GameManager) so they participate in engine process loops, physics, and input events.
  3. Engine Singleton Registration: The instance is registered in Godot's native Engine singleton registry (Engine.get_singleton("GameManager")), making it accessible from both Crystal and GDScript.
  4. Type-Safe Accessors: The macro generates typed class-level accessors:
    # Non-nil typed accessor (raises if not initialized)
    GameManager.instance.add_score(100)
    
    # Nilable typed accessor
    if gm = GameManager.instance?
      Godot.print("Score: #{gm.score}")
    end
    
    # Generic engine lookup
    node = Godot.autoload(GameManager)

Customizing Autoload Registration:

You can customize the singleton name, disable engine singleton registration, or disable SceneTree mounting:

# Custom name and tree-only (no Engine.register_singleton)
@[Autoload(name: "AudioService", singleton: false)]
node CustomAudioManager < AudioStreamPlayer do
end

# Or using in-body directive:
node InventoryManager < Node do
  autoload name: "Inventory", singleton: true, mount_tree: true
end