# Godot 4 — Screen Transitions

> **Purpose**: Reference document for Claude Code. Transition types by emotional context, timing values, shader implementations, scene loading patterns, and audio sync from shipped games. The marinade — not SceneTree.change_scene basics.

---

## 1. Transition Types by Emotional Context

The transition IS the emotion between two moments. A fade-to-black on a door feels like death. An iris wipe on a door feels like adventure. The wrong transition breaks the mood on both sides.

| Transition | Emotional Context | Examples in Games |
|-----------|-------------------|-------------------|
| **Fade to black** | Death, serious scene change, finality, sleep | Hollow Knight (benches, death), Dark Souls |
| **Fade to white** | Ascension, memory, divine, rebirth | Celeste (summit), Ori (spirit wells) |
| **Iris wipe (circle)** | Focus on character, classic adventure, warmth | Zelda (doors/caves), Mario (pipe entry) |
| **Diamond wipe** | Retro, pixel art, 8-bit/16-bit aesthetic | Shovel Knight, retro-styled games |
| **Dissolve (noise)** | Dream, memory, corruption, uncertainty | Hollow Knight (dream nail), JRPG flashbacks |
| **Slide/push** | Adjacent rooms, lateral movement, panels | Celeste (room-to-room), comic-style games |
| **Pixelate** | Retro, game-within-game, digital, glitch | Undertale, retro transitions |
| **Custom mask** | Themed to game identity | Persona 5 (ink splatter), Hades (blood) |

### When NOT to Transition

Some transitions should be invisible:
- **Metroidvania room-to-room**: camera lerps to new bounds, NO fade. The world is continuous. A fade breaks immersion.
- **Menu → Gameplay resume**: instant cut or 0.1s fade max. The player is waiting to play.
- **Repeated short transitions** (doors in quick succession): shorten or remove. Zelda: Link Between Worlds made door transitions near-instant because of the density.

---

## 2. Timing That Feels Right

### The Asymmetric Rule

Fade-out should be **faster** than fade-in. The player is leaving something (done, let go) and arriving somewhere (new, needs time to orient).

| Phase | Duration | Why |
|-------|----------|-----|
| **Fade-out** | 0.3–0.5s | Quick enough to feel responsive. Slow enough to register |
| **Hold on black** | 0.1–0.3s | Gives the brain a "reset." Swap scenes here. Too long = player wonders if it crashed |
| **Fade-in** | 0.3–0.5s | Reveal the new world gradually. Slightly longer than fade-out |

### Duration by Context

| Context | Fade-out | Hold | Fade-in | Total | Notes |
|---------|----------|------|---------|-------|-------|
| **Door / Room change** | 0.2s | 0.1s | 0.3s | 0.6s | Fast. Player expects to keep moving |
| **Death** | 0.5s | 0.5s | 0.4s | 1.4s | Slow fade-out = sinking feeling. Long hold = weight |
| **Cutscene entry** | 0.4s | 0.2s | 0.5s | 1.1s | Cinematic pacing |
| **Fast travel / teleport** | 0.3s | 0.15s | 0.4s | 0.85s | Medium — player chose this, don't waste their time |
| **Boss arena entry** | 0.5s | 0.3s | 0.6s | 1.4s | Dramatic. Hold = anticipation. Slow reveal = "here it comes" |
| **Memory / flashback** | 0.6s | 0.4s | 0.8s | 1.8s | Dreamy. Slow everything. Use fade-to-white |
| **Level complete** | 0.3s | 1.0s | 0.5s | 1.8s | Quick fade, long hold for score display, slow reveal of next area |

### Hollow Knight's Bench Transition

Hollow Knight's bench rest uses a ~1.2s total transition:
- Sit animation plays (0.3s)
- Fade to black over 0.5s with audio fading
- Hold black 0.3s (save happens here)
- Fade in over 0.4s with ambient audio returning
- The hold-on-black is barely noticeable because the sit animation provides visual continuity

### Celeste's Room Transition

Celeste rooms transition with NO fade — the camera slides to the adjacent screen. This takes about 0.25-0.35s. During the slide, Madeline's position wraps to the new room edge. The player retains momentum, which is critical for speedrun tech.

---

## 3. The CanvasLayer + ColorRect Setup

Every transition system needs the same foundation: a CanvasLayer above everything, a ColorRect covering the screen, driven by either a shader or modulate alpha.

### Base Scene Structure

```
TransitionLayer (CanvasLayer)
├── layer = 128 (above everything)
├── ColorRect (covers full viewport)
│   ├── anchors = full rect
│   ├── mouse_filter = MOUSE_FILTER_IGNORE
│   └── color = Color.BLACK (or shader material)
```

### Transition Manager (Autoload)

```gdscript
## Autoload: manages all screen transitions.
## Usage: Transition.fade_to_black(func(): get_tree().change_scene_to_file("res://next.tscn"))
extends CanvasLayer

@onready var color_rect: ColorRect = $ColorRect
var _is_transitioning: bool = false


func _ready() -> void:
    layer = 128
    color_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
    color_rect.color = Color.BLACK
    color_rect.modulate.a = 0.0
    # Ensure full coverage
    color_rect.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)


## Simple fade to black → callback → fade in
func fade_to_black(callback: Callable, fade_out: float = 0.4, hold: float = 0.15, fade_in: float = 0.4) -> void:
    if _is_transitioning:
        return
    _is_transitioning = true
    color_rect.color = Color.BLACK

    var tween := create_tween()
    # Fade out (screen goes dark)
    tween.tween_property(color_rect, "modulate:a", 1.0, fade_out)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
    # Hold on black
    tween.tween_interval(hold)
    # Execute callback (scene swap happens here)
    tween.tween_callback(callback)
    # Wait one frame for scene to initialize
    tween.tween_callback(func() -> void: await get_tree().process_frame)
    tween.tween_interval(0.05)
    # Fade in (new scene revealed)
    tween.tween_property(color_rect, "modulate:a", 0.0, fade_in)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
    tween.tween_callback(func() -> void: _is_transitioning = false)


## Fade to white (memory, ascension, rebirth)
func fade_to_white(callback: Callable, fade_out: float = 0.5, hold: float = 0.3, fade_in: float = 0.6) -> void:
    if _is_transitioning:
        return
    _is_transitioning = true
    color_rect.color = Color.WHITE

    var tween := create_tween()
    tween.tween_property(color_rect, "modulate:a", 1.0, fade_out)\
        .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
    tween.tween_interval(hold)
    tween.tween_callback(callback)
    tween.tween_interval(0.05)
    tween.tween_property(color_rect, "modulate:a", 0.0, fade_in)\
        .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
    tween.tween_callback(func() -> void: _is_transitioning = false)


func is_active() -> bool:
    return _is_transitioning
```

### Usage From Any Scene

```gdscript
## Door interaction
func _on_door_entered() -> void:
    Transition.fade_to_black(
        func() -> void:
            get_tree().change_scene_to_file("res://scenes/next_room.tscn"),
        0.3, 0.1, 0.4  # Fast door transition
    )

## Death
func _on_player_died() -> void:
    Transition.fade_to_black(
        func() -> void:
            get_tree().reload_current_scene(),
        0.5, 0.5, 0.4  # Slow, heavy death transition
    )

## Memory flashback
func _on_memory_triggered() -> void:
    Transition.fade_to_white(
        func() -> void:
            get_tree().change_scene_to_file("res://scenes/memory.tscn"),
        0.6, 0.4, 0.8  # Dreamy, slow
    )
```

---

## 4. Shader Transitions

### Circle Iris Wipe

The Zelda classic — a circle closes around the player's position, or opens to reveal the new scene.

```gdshader
shader_type canvas_item;

uniform float progress : hint_range(0.0, 1.0) = 0.0;
uniform vec2 center = vec2(0.5, 0.5);  // UV coordinates of iris center
uniform float softness : hint_range(0.0, 0.1) = 0.01;  // Edge softness
uniform vec4 color : source_color = vec4(0.0, 0.0, 0.0, 1.0);

void fragment() {
    // Max possible distance from center (corner distance)
    float max_dist = max(
        max(length(center), length(vec2(1.0, 1.0) - center)),
        max(length(vec2(1.0, 0.0) - center), length(vec2(0.0, 1.0) - center))
    );

    float dist = length(UV - center);
    float radius = progress * max_dist * 1.1;  // 1.1 to ensure full coverage

    // Smooth edge
    float mask = smoothstep(radius - softness, radius + softness, dist);

    COLOR = vec4(color.rgb, mask);
}
```

**Usage**: `progress` goes from 0.0 (fully open) to 1.0 (fully closed). For iris-in (closing), tween progress 0→1. For iris-out (opening), tween progress 1→0.

**Center tracking**: set `center` to the player's screen UV position for the Zelda effect:

```gdscript
func get_player_screen_uv(player: Node2D) -> Vector2:
    var viewport := get_viewport()
    var screen_pos := viewport.get_camera_2d().get_screen_center_position()
    var player_offset := player.global_position - screen_pos
    var viewport_size := Vector2(viewport.size)
    return (player_offset / viewport_size) + Vector2(0.5, 0.5)
```

### Diamond Wipe

Retro-style diamond pattern transition. Each pixel belongs to a diamond tile, and tiles reveal/hide based on progress + position.

```gdshader
shader_type canvas_item;

uniform float progress : hint_range(0.0, 1.0) = 0.0;
uniform float diamond_size : hint_range(4.0, 64.0) = 10.0;
uniform vec4 color : source_color = vec4(0.0, 0.0, 0.0, 1.0);

void fragment() {
    // Diamond grid coordinates
    float x_frac = fract(FRAGCOORD.x / diamond_size);
    float y_frac = fract(FRAGCOORD.y / diamond_size);

    // Manhattan distance from diamond center
    float x_dist = abs(x_frac - 0.5);
    float y_dist = abs(y_frac - 0.5);

    // Directional sweep: progress moves across the screen
    float threshold = progress * 4.0;
    float diamond = x_dist + y_dist + UV.x + UV.y;

    // 1.0 = covered (black), 0.0 = visible
    float mask = step(diamond, threshold);

    COLOR = vec4(color.rgb, mask);
}
```

**Diamond size values**:
- 8-12px: classic 8-bit feel (NES Zelda)
- 16-24px: 16-bit feel (SNES)
- 32-48px: stylized modern pixel art
- 64px+: chunky, intentionally blocky

### Dissolve (Noise-Based)

Pixels dissolve away based on noise texture. Organic, unpredictable — perfect for dream sequences, corruption, or teleportation.

```gdshader
shader_type canvas_item;

uniform float progress : hint_range(0.0, 1.0) = 0.0;
uniform sampler2D noise_texture;
uniform float edge_width : hint_range(0.0, 0.2) = 0.05;
uniform vec4 edge_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
uniform vec4 fill_color : source_color = vec4(0.0, 0.0, 0.0, 1.0);

void fragment() {
    float noise = texture(noise_texture, UV).r;

    // Edge glow at the dissolve boundary
    float edge = smoothstep(progress - edge_width, progress, noise)
               - smoothstep(progress, progress + edge_width, noise);

    // Main dissolve mask
    float mask = step(noise, progress);

    vec3 final_color = mix(fill_color.rgb, edge_color.rgb, edge);
    COLOR = vec4(final_color, max(mask, edge));
}
```

**Noise texture tips**:
- Use `FastNoiseLite` in Godot → set noise_type to `Simplex Smooth` for organic dissolve
- `Cellular` noise for a "cracking" dissolve
- `Value` noise for pixelated dissolve (good for pixel art)
- Size: 256x256 is enough. Seamless = on.

**Edge color by mood**:
- White `Color(1.0, 1.0, 1.0)`: dream, memory, light
- Purple `Color(0.6, 0.0, 0.8)`: corruption, dark magic
- Orange `Color(1.0, 0.5, 0.0)`: fire, destruction
- Cyan `Color(0.0, 0.8, 1.0)`: digital, teleport

### Pixelate Transition

Screen breaks into increasingly large pixels before going solid. Retro-digital feel.

```gdshader
shader_type canvas_item;

uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_nearest;
uniform float progress : hint_range(0.0, 1.0) = 0.0;
uniform float max_pixel_size : hint_range(2.0, 128.0) = 64.0;

void fragment() {
    // Pixel size increases with progress
    float pixel_size = mix(1.0, max_pixel_size, progress * progress);  // Quadratic ramp

    // Snap UV to pixel grid
    vec2 pixel_uv = floor(SCREEN_UV * (1.0 / SCREEN_PIXEL_SIZE) / pixel_size)
                   * pixel_size * SCREEN_PIXEL_SIZE;

    vec4 color = texture(screen_texture, pixel_uv);

    // Fade to black in the last 20% of progress
    float fade = smoothstep(0.8, 1.0, progress);
    color.rgb = mix(color.rgb, vec3(0.0), fade);

    COLOR = color;
}
```

**Timing**: pixelate works best with a fast ramp (0.3-0.5s). The pixelation should reach max_pixel_size at about 80% progress, then the last 20% fades to solid. This avoids the awkward "fully pixelated but still visible" state.

### Directional Wipe

A hard edge sweeps across the screen in any direction. Clean, mechanical.

```gdshader
shader_type canvas_item;

uniform float progress : hint_range(0.0, 1.0) = 0.0;
uniform float angle : hint_range(0.0, 6.28318) = 0.0;  // Radians. 0=right, PI/2=down
uniform float softness : hint_range(0.0, 0.3) = 0.02;
uniform vec4 color : source_color = vec4(0.0, 0.0, 0.0, 1.0);

void fragment() {
    vec2 dir = vec2(cos(angle), sin(angle));
    // Project UV onto direction vector
    float d = dot(UV - vec2(0.5), dir) + 0.5;

    float mask = smoothstep(progress - softness, progress + softness, d);
    COLOR = vec4(color.rgb, mask);
}
```

**Angle presets**:
- `0.0` = wipe from left to right
- `PI` (3.14159) = wipe from right to left
- `PI / 2.0` (1.5708) = wipe from top to bottom
- `PI * 1.5` (4.7124) = wipe from bottom to top
- `PI / 4.0` (0.7854) = diagonal

---

## 5. Shader Transition Manager

Driving shader transitions from GDScript. Extends the base Transition autoload with shader support.

```gdscript
## Extended transition manager with shader support.
## Requires ColorRect to have a ShaderMaterial.
extends CanvasLayer

@onready var color_rect: ColorRect = $ColorRect
var _is_transitioning: bool = false

## Shader-based transition (iris, diamond, dissolve, etc.)
## The shader must have a "progress" uniform (0.0 = visible, 1.0 = covered)
func shader_transition(
    callback: Callable,
    fade_out: float = 0.4,
    hold: float = 0.15,
    fade_in: float = 0.4,
    out_ease: Tween.TransitionType = Tween.TRANS_QUAD,
    in_ease: Tween.TransitionType = Tween.TRANS_QUAD
) -> void:
    if _is_transitioning:
        return
    _is_transitioning = true
    var mat := color_rect.material as ShaderMaterial
    color_rect.visible = true

    var tween := create_tween()
    # Close transition
    tween.tween_method(
        func(v: float) -> void: mat.set_shader_parameter("progress", v),
        0.0, 1.0, fade_out
    ).set_trans(out_ease).set_ease(Tween.EASE_IN)
    # Hold
    tween.tween_interval(hold)
    # Swap scene
    tween.tween_callback(callback)
    tween.tween_interval(0.05)
    # Open transition
    tween.tween_method(
        func(v: float) -> void: mat.set_shader_parameter("progress", v),
        1.0, 0.0, fade_in
    ).set_trans(in_ease).set_ease(Tween.EASE_OUT)
    tween.tween_callback(func() -> void:
        _is_transitioning = false
        color_rect.visible = false
    )


## Iris wipe centered on a world position
func iris_wipe(
    callback: Callable,
    focus_pos: Vector2 = Vector2(0.5, 0.5),
    fade_out: float = 0.4,
    hold: float = 0.15,
    fade_in: float = 0.4
) -> void:
    var mat := color_rect.material as ShaderMaterial
    mat.set_shader_parameter("center", focus_pos)
    shader_transition(callback, fade_out, hold, fade_in)


## Diamond wipe with configurable diamond size
func diamond_wipe(
    callback: Callable,
    diamond_size: float = 10.0,
    fade_out: float = 0.4,
    hold: float = 0.15,
    fade_in: float = 0.4
) -> void:
    var mat := color_rect.material as ShaderMaterial
    mat.set_shader_parameter("diamond_size", diamond_size)
    shader_transition(callback, fade_out, hold, fade_in)
```

### Switching Shaders at Runtime

```gdscript
## Preload all transition shaders
var iris_shader := preload("res://shaders/transition_iris.gdshader")
var diamond_shader := preload("res://shaders/transition_diamond.gdshader")
var dissolve_shader := preload("res://shaders/transition_dissolve.gdshader")
var pixelate_shader := preload("res://shaders/transition_pixelate.gdshader")

func set_transition_shader(shader: Shader) -> void:
    var mat := ShaderMaterial.new()
    mat.shader = shader
    color_rect.material = mat
```

---

## 6. The Zelda Door Transition

The complete sequence, step by step. This is the pattern most top-down/side-scroll adventure games use.

### Sequence

1. **Player enters door trigger** → disable player input
2. **Camera lock** → stop camera from following player
3. **Player walk animation** → auto-walk into door (0.3s)
4. **Iris close** → centered on door position (0.4s)
5. **Hold black** → swap scene, teleport player to exit door (0.15s)
6. **Iris open** → centered on exit door, or on player (0.4s)
7. **Camera unlock** → camera snaps or lerps to player
8. **Player walk out** → auto-walk out of door (0.2s)
9. **Re-enable input**

### Implementation

```gdscript
## Door script — handles the full transition sequence
extends Area2D

@export var target_scene: String = ""
@export var target_door_name: String = ""
@export var exit_direction: Vector2 = Vector2.DOWN


func _on_body_entered(body: Node2D) -> void:
    if body.name != "Player" or Transition.is_active():
        return

    var player := body
    _do_door_transition(player)


func _do_door_transition(player: Node2D) -> void:
    # 1. Disable player input
    player.set_process_input(false)
    player.velocity = Vector2.ZERO

    # 2. Auto-walk into door
    var walk_tween := create_tween()
    walk_tween.tween_property(player, "position", position, 0.3)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT)
    await walk_tween.finished

    # 3. Iris close centered on door
    var door_uv := _get_screen_uv(position)
    Transition.iris_wipe(
        func() -> void: _swap_and_place(player),
        door_uv, 0.4, 0.15, 0.4
    )


func _swap_and_place(_player: Node2D) -> void:
    # Scene swap happens here — player is re-created in new scene
    # The target scene's matching door places the player at its exit
    get_tree().change_scene_to_file(target_scene)


func _get_screen_uv(world_pos: Vector2) -> Vector2:
    var cam := get_viewport().get_camera_2d()
    if not cam:
        return Vector2(0.5, 0.5)
    var screen_center := cam.get_screen_center_position()
    var vp_size := Vector2(get_viewport().size)
    return (world_pos - screen_center) / vp_size + Vector2(0.5, 0.5)
```

### Exit Door (Target Scene Side)

```gdscript
## ExitDoor — placed in target scene, spawns player at its position
extends Marker2D

@export var door_name: String = ""
@export var exit_offset: Vector2 = Vector2(0.0, 32.0)  # Where player appears


func spawn_player_here(player: Node2D) -> void:
    player.global_position = global_position
    player.set_process_input(false)

    # Auto-walk out of door
    var tween := create_tween()
    tween.tween_property(player, "global_position",
        global_position + exit_offset, 0.25)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT)
    tween.tween_callback(func() -> void: player.set_process_input(true))
```

---

## 7. Metroidvania Room Transitions

No fade. The camera slides to the new room bounds. The world is one continuous space.

### Camera Bounds System

```gdscript
## Attach to Camera2D. Manages room bounds and smooth transitions between them.
class_name RoomCamera
extends Camera2D

@export var transition_speed: float = 0.3
@export var lock_player_during_transition: bool = true

var current_bounds: Rect2 = Rect2()
var _is_transitioning: bool = false


func set_room_bounds(bounds: Rect2, instant: bool = false) -> void:
    if bounds == current_bounds:
        return

    var old_bounds := current_bounds
    current_bounds = bounds

    # Update camera limits
    if instant or old_bounds.size == Vector2.ZERO:
        _apply_limits(bounds)
        return

    _transition_to_bounds(bounds)


func _apply_limits(bounds: Rect2) -> void:
    limit_left = int(bounds.position.x)
    limit_top = int(bounds.position.y)
    limit_right = int(bounds.end.x)
    limit_bottom = int(bounds.end.y)


func _transition_to_bounds(bounds: Rect2) -> void:
    _is_transitioning = true

    # Temporarily disable camera smoothing for direct control
    var was_smoothing := position_smoothing_enabled
    position_smoothing_enabled = false

    var tween := create_tween().set_parallel(true)
    tween.tween_property(self, "limit_left", int(bounds.position.x), transition_speed)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT)
    tween.tween_property(self, "limit_top", int(bounds.position.y), transition_speed)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT)
    tween.tween_property(self, "limit_right", int(bounds.end.x), transition_speed)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT)
    tween.tween_property(self, "limit_bottom", int(bounds.end.y), transition_speed)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT)

    await tween.finished
    position_smoothing_enabled = was_smoothing
    _is_transitioning = false


func is_transitioning() -> bool:
    return _is_transitioning
```

### Room Trigger Zone

```gdscript
## Place at room boundaries. Triggers camera bound change when player enters.
extends Area2D

@export var room_bounds: Rect2 = Rect2(0, 0, 320, 180)


func _on_body_entered(body: Node2D) -> void:
    if body.name != "Player":
        return
    var cam := body.get_viewport().get_camera_2d() as RoomCamera
    if cam:
        cam.set_room_bounds(room_bounds)
```

### Parallax During Room Transition

Parallax layers must respect room bounds or they reveal out-of-bounds content during the camera slide.

```gdscript
## Clamp parallax layers to room bounds during transition
func _process(_delta: float) -> void:
    if not camera or not camera.is_transitioning():
        return
    # Freeze parallax during room transition to avoid peeking
    for layer in get_children():
        if layer is ParallaxLayer:
            layer.motion_offset = Vector2.ZERO
```

---

## 8. Scene Loading Masked by Transitions

### The LoadingScreen Anti-Pattern

DON'T: fade to black → show loading screen → loading bar fills → fade in.

For most indie games, scenes load in < 0.5s. The loading screen adds 2-3 seconds of UX overhead for zero benefit. It also breaks immersion — the player is reminded they're playing a video game with "scenes."

### The Right Pattern: Preload During Black Screen

```gdscript
## Transition with background loading — scene swaps during hold-on-black
func transition_with_preload(scene_path: String, fade_out: float = 0.4) -> void:
    if _is_transitioning:
        return
    _is_transitioning = true

    # Start threaded loading immediately
    ResourceLoader.load_threaded_request(scene_path)

    # Fade out
    var tween := create_tween()
    tween.tween_property(color_rect, "modulate:a", 1.0, fade_out)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
    await tween.finished

    # Wait for scene to finish loading (usually already done)
    while ResourceLoader.load_threaded_get_status(scene_path) != ResourceLoader.THREAD_LOAD_LOADED:
        await get_tree().process_frame

    # Swap scene while screen is black
    var packed_scene := ResourceLoader.load_threaded_get(scene_path) as PackedScene
    get_tree().change_scene_to_packed(packed_scene)
    await get_tree().process_frame  # Let new scene initialize

    # Fade in
    var fade_in_tween := create_tween()
    fade_in_tween.tween_property(color_rect, "modulate:a", 0.0, 0.4)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
    fade_in_tween.tween_callback(func() -> void: _is_transitioning = false)
```

### When You Actually Need a Loading Screen

If scenes take > 1 second to load (large open worlds, procedural generation), then use a loading screen. But make it a TRANSITION, not a screen:

```gdscript
## Minimal loading transition — a progress bar ON the black screen
## No separate loading scene, no logo splash, no tips
func transition_with_progress(scene_path: String) -> void:
    _is_transitioning = true
    ResourceLoader.load_threaded_request(scene_path)

    # Fade out
    var tween := create_tween()
    tween.tween_property(color_rect, "modulate:a", 1.0, 0.4)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
    await tween.finished

    # Show minimal progress bar (just a thin line at the bottom)
    var progress_bar := _create_thin_bar()
    add_child(progress_bar)

    while ResourceLoader.load_threaded_get_status(scene_path) != ResourceLoader.THREAD_LOAD_LOADED:
        var progress: Array = []
        ResourceLoader.load_threaded_get_status(scene_path, progress)
        progress_bar.value = progress[0] * 100.0
        await get_tree().process_frame

    progress_bar.queue_free()
    var packed := ResourceLoader.load_threaded_get(scene_path) as PackedScene
    get_tree().change_scene_to_packed(packed)
    await get_tree().process_frame

    var reveal := create_tween()
    reveal.tween_property(color_rect, "modulate:a", 0.0, 0.4)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
    reveal.tween_callback(func() -> void: _is_transitioning = false)


func _create_thin_bar() -> ProgressBar:
    var bar := ProgressBar.new()
    bar.max_value = 100.0
    bar.show_percentage = false
    bar.custom_minimum_size = Vector2(200, 3)
    bar.set_anchors_and_offsets_preset(Control.PRESET_CENTER_BOTTOM)
    bar.position.y -= 40
    return bar
```

---

## 9. Sound Design for Transitions

### Timing Rules

| Rule | Details |
|------|---------|
| **SFX starts on fade-out, not on black** | If the whoosh plays when the screen is already black, the player hears sound with no visual cause. Start the SFX 0.05-0.1s before or at the same time as the fade begins |
| **Fade-in SFX = softer than fade-out SFX** | The exit sound should be less aggressive — the player is arriving, not departing |
| **Music crossfade starts mid-transition** | Don't cut music at black. Start fading old music at ~50% of fade-out. Start new music at ~30% of fade-in. The overlap creates continuity |
| **Ambient audio follows the same curve** | Wind, rain, crowd noise fade with the visual. Silence during hold-on-black feels "dead" — keep a low ambient drone at -30dB |

### Audio Integration with Transitions

```gdscript
## Extend the transition manager with audio
func fade_with_audio(
    callback: Callable,
    transition_sfx: AudioStream = null,
    fade_out: float = 0.4,
    hold: float = 0.15,
    fade_in: float = 0.4
) -> void:
    if _is_transitioning:
        return
    _is_transitioning = true

    # Play transition SFX at the start of fade-out
    if transition_sfx:
        var player := AudioStreamPlayer.new()
        player.stream = transition_sfx
        player.bus = "SFX"
        add_child(player)
        player.play()
        player.finished.connect(player.queue_free)

    # Fade music volume during transition
    var music_bus := AudioServer.get_bus_index("Music")
    var original_db := AudioServer.get_bus_volume_db(music_bus)

    var tween := create_tween()
    # Fade out visual + music
    tween.set_parallel(true)
    tween.tween_property(color_rect, "modulate:a", 1.0, fade_out)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
    tween.tween_method(
        func(db: float) -> void: AudioServer.set_bus_volume_db(music_bus, db),
        original_db, -40.0, fade_out
    ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)

    # Sequential: hold, swap, fade in
    tween.chain()
    tween.tween_interval(hold)
    tween.tween_callback(callback)
    tween.tween_interval(0.05)

    # Fade in visual + music
    tween.set_parallel(true)
    tween.tween_property(color_rect, "modulate:a", 0.0, fade_in)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
    tween.tween_method(
        func(db: float) -> void: AudioServer.set_bus_volume_db(music_bus, db),
        -40.0, original_db, fade_in
    ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)

    tween.chain()
    tween.tween_callback(func() -> void: _is_transitioning = false)
```

### Transition SFX by Type

| Transition | SFX Character | Duration | Notes |
|-----------|---------------|----------|-------|
| **Fade to black** | Low swoosh, downward pitch | 0.3-0.5s | Should match fade-out duration |
| **Fade to white** | Rising shimmer, bright | 0.4-0.6s | Ethereal, higher frequency |
| **Iris wipe** | Quick circular swoosh | 0.2-0.4s | Tight, focused sound |
| **Diamond wipe** | Pixelated/digital sweep | 0.3-0.5s | Crystalline, staccato |
| **Dissolve** | Crumbling, granular noise | 0.3-0.5s | Matches the visual texture |
| **Death fade** | Low boom + reverb tail | 0.5-0.8s | Heavy, final |

---

## 10. Combining Techniques (Stack Examples)

### Stack: "Enter Dungeon Door"

Total time: ~1.0s. Classic adventure feel.

1. **t=0.00**: Disable player input. Play door SFX (creak/slam)
2. **t=0.00**: Player auto-walks toward door over 0.25s
3. **t=0.25**: Iris close centered on door UV, 0.35s, `TRANS_QUAD` `EASE_IN`
4. **t=0.25**: Music fades to -30dB over 0.3s
5. **t=0.60**: Screen fully black. Swap scene. Teleport player.
6. **t=0.75**: Iris open centered on exit door, 0.4s, `TRANS_QUAD` `EASE_OUT`
7. **t=0.75**: New area music fades in from -40dB over 0.5s
8. **t=1.15**: Player auto-walks out. Re-enable input.

### Stack: "Player Death"

Total time: ~2.0s. Heavy, punishing, but not frustrating.

1. **t=0.00**: Hitstop 0.1s. Death SFX (impact + low boom)
2. **t=0.10**: Player sprite: flash white, then dissolve (sprite shader)
3. **t=0.10**: Camera shake, intensity 6px, 0.2s
4. **t=0.30**: Fade to black starts, 0.5s, `TRANS_SINE` `EASE_IN`
5. **t=0.30**: Music ducks to -20dB over 0.5s
6. **t=0.80**: Screen fully black. Hold 0.5s (the weight of death)
7. **t=1.30**: Reload scene / reset to checkpoint
8. **t=1.35**: Fade in, 0.5s, `TRANS_SINE` `EASE_OUT`. Music returns.
9. **t=1.85**: Player control restored.

### Stack: "Dream/Memory Flashback"

Total time: ~2.5s. Ethereal, disorienting.

1. **t=0.00**: Screen desaturates over 0.3s (shader saturation 1.0→0.3)
2. **t=0.00**: Rising shimmer SFX
3. **t=0.20**: Dissolve transition starts (noise-based, white edge glow), 0.6s
4. **t=0.20**: Music crossfade begins (current → memory theme)
5. **t=0.80**: Fully white/dissolved. Hold 0.4s.
6. **t=1.20**: Swap to memory scene
7. **t=1.25**: Dissolve opens (reverse), 0.8s — slower reveal for wonder
8. **t=1.25**: Film grain shader activates (amount 0.06)
9. **t=1.25**: Warm temperature shift (+0.2) for nostalgic feel
10. **t=2.05**: Memory scene fully visible. Player control restored.

### Stack: "Retro Level Select to Gameplay"

Total time: ~0.9s. Snappy, game-y.

1. **t=0.00**: Selection confirmed SFX (coin/chime)
2. **t=0.00**: Selected level card scales to 1.2x, 0.1s, `TRANS_BACK`
3. **t=0.10**: Diamond wipe close (size 12px), 0.3s
4. **t=0.40**: Swap scene while black.
5. **t=0.45**: Diamond wipe open (size 12px), 0.35s
6. **t=0.45**: Level intro music starts
7. **t=0.80**: Fully visible. Input enabled.

---

## Gotchas

1. **CanvasLayer `layer` must be above ALL other CanvasLayers** — if your HUD is on layer 10, the transition must be on layer 11+. Using 128 is safe. Otherwise the HUD peeks through the transition
2. **`mouse_filter = MOUSE_FILTER_IGNORE` on the ColorRect** — without this, the transition eats all mouse input when visible, even at alpha 0.0. The player can't click through it
3. **`change_scene_to_file` frees the current scene** — any node references from the old scene become invalid. The Transition autoload survives because it's an autoload, not part of any scene. Never store scene-specific references in the transition manager
4. **`change_scene_to_packed` needs a PackedScene, not an instance** — `preload("res://scene.tscn").instantiate()` passed to `change_scene_to_packed()` = crash. Pass the `preload()` directly, not the instance
5. **Shader progress 0→1 = closing, 1→0 = opening** — if your shader is inverted (0=black, 1=visible), the transition will play backwards. Always test with a manual slider in the inspector first
6. **Dissolve noise texture size matters** — a 32x32 noise looks blocky/tiled at 1080p. 256x256 minimum. Set `seamless = true` in the NoiseTexture resource
7. **Pixelate shader needs `filter_nearest`** — without `filter_nearest` on the screen_texture sampler, the big pixels get blurry anti-aliased edges, which defeats the whole point
8. **Audio during `hold` phase** — if you mute everything during hold-on-black, the silence feels like a bug. Keep a subtle ambient at -30dB or a low reverb tail from the transition SFX
9. **Multiple rapid transitions** — if the player enters two doors in 0.5s (edge case), the second transition fires while the first is running. The `_is_transitioning` guard prevents this, but test it
10. **Transition autoload + MCP** — `GolemCapture` screenshots see the transition overlay. A screenshot during hold-on-black = a black screenshot. Call `get_game_screenshot` after the transition finishes, not during
