From godot-ai-builder
Game feel, juice, and visual quality. The difference between a prototype and a game people want to play. Use in Phase 5 of the Director workflow, or whenever the user says "make it look good", "add polish", "add juice", or "it feels flat".
npx claudepluginhub hubdev-ai/godot-ai-builderThis skill uses the workspace's default tool permissions.
| Action | Visual Feedback | Audio Feedback | Camera |
Analyzes and improves UI/UX of browser games with visual polish, atmospheres, backgrounds, particles, animations, transitions, and juice effects. Use for aesthetic and player experience upgrades.
Provides expert guidance on Godot UI using Control nodes, themes, styling, responsive layouts, and patterns for menus, HUDs, inventories, dialogues. Use for UI/menu creation or styling.
Constructs Godot scenes from patterns like platformer characters, top-down chars, UI screens, projectiles, pickups, tilemaps with required companion nodes (e.g., CollisionShape2D).
Share bugs, ideas, or general feedback.
| Action | Visual Feedback | Audio Feedback | Camera |
|---|---|---|---|
| Player shoots | Muzzle flash + recoil | "pew" SFX | Tiny shake (1px, 0.03s) |
| Bullet hits enemy | Flash white + particles | "hit" SFX | Small shake (3px, 0.08s) |
| Enemy dies | Explosion particles + dissolve | "explode" SFX | Medium shake (5px, 0.12s) |
| Player takes damage | Red flash + knockback | "hurt" SFX | Big shake (8px, 0.15s) |
| Player dies | Slow-mo + big explosion | "death" SFX | Big shake + zoom out |
| Pickup collected | Scale punch + float text + glow | "collect" SFX | None |
| Score change | Number pops + scale punch | "ding" SFX | None |
| Wave complete | Flash screen white briefly | "fanfare" SFX | Zoom out briefly |
| Menu button click | Scale down->up tween | "click" SFX | None |
| Scene transition | Dissolve / fade / slide | Swoosh SFX | None |
# Create shaders/hit_flash.gdshader with this content:
# shader_type canvas_item;
# uniform float flash_amount : hint_range(0.0, 1.0) = 0.0;
# uniform vec4 flash_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
# void fragment() {
# vec4 tex = texture(TEXTURE, UV);
# COLOR = mix(tex, flash_color * tex.a, flash_amount);
# }
func apply_hit_flash_shader(node: CanvasItem):
var mat = ShaderMaterial.new()
mat.shader = load("res://shaders/hit_flash.gdshader")
node.material = mat
func flash_hit(node: CanvasItem, color: Color = Color.WHITE, duration: float = 0.12):
if node.material is ShaderMaterial:
node.material.set_shader_parameter("flash_amount", 1.0)
node.material.set_shader_parameter("flash_color", color)
var tw = create_tween()
tw.tween_method(func(v): node.material.set_shader_parameter("flash_amount", v), 1.0, 0.0, duration)
else:
# Fallback if no shader
node.modulate = color * 3
var tw = node.create_tween()
tw.tween_property(node, "modulate", Color.WHITE, duration)
# Create shaders/dissolve.gdshader with this content:
# shader_type canvas_item;
# uniform float dissolve_amount : hint_range(0.0, 1.0) = 0.0;
# uniform vec4 edge_color : source_color = vec4(1.0, 0.3, 0.0, 1.0);
# uniform float edge_width : hint_range(0.0, 0.2) = 0.05;
# void fragment() {
# vec4 tex = texture(TEXTURE, UV);
# float noise = fract(sin(dot(UV * 50.0, vec2(12.9898, 78.233))) * 43758.5453);
# if (noise < dissolve_amount) { discard; }
# else if (noise < dissolve_amount + edge_width) { COLOR = edge_color; }
# else { COLOR = tex; }
# }
func dissolve_death(node: CanvasItem, color: Color = Color(1, 0.3, 0)):
var mat = ShaderMaterial.new()
mat.shader = load("res://shaders/dissolve.gdshader")
mat.set_shader_parameter("edge_color", color)
node.material = mat
# Disable collision
if node is CollisionObject2D:
node.collision_layer = 0
node.collision_mask = 0
var tw = create_tween()
tw.tween_method(func(v): mat.set_shader_parameter("dissolve_amount", v), 0.0, 1.0, 0.5)
tw.tween_callback(node.queue_free)
# Create shaders/glow_outline.gdshader — see godot-assets skill for full code
func apply_glow_outline(node: CanvasItem, color: Color, width: float = 2.0):
var mat = ShaderMaterial.new()
mat.shader = load("res://shaders/glow_outline.gdshader")
mat.set_shader_parameter("outline_color", color)
mat.set_shader_parameter("outline_width", width)
node.material = mat
func spawn_with_animation(node: Node2D, target_scale: Vector2 = Vector2.ONE):
node.scale = Vector2.ZERO
node.modulate.a = 0.0
add_child(node)
var tw = node.create_tween()
tw.set_parallel(true)
tw.tween_property(node, "scale", target_scale, 0.3).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK)
tw.tween_property(node, "modulate:a", 1.0, 0.2)
func die_with_style(node: Node2D, color: Color = Color.RED):
# Disable physics/logic
node.set_physics_process(false)
if node is CollisionObject2D:
node.collision_layer = 0
node.collision_mask = 0
# Try dissolve shader first (best visual)
if ResourceLoader.exists("res://shaders/dissolve.gdshader"):
dissolve_death(node, color)
_spawn_death_particles(node.global_position, color)
return
# Fallback: burst particles + shrink
_spawn_death_particles(node.global_position, color)
var tw = node.create_tween()
tw.set_parallel(true)
tw.tween_property(node, "scale", Vector2.ZERO, 0.2).set_ease(Tween.EASE_IN).set_trans(Tween.TRANS_BACK)
tw.tween_property(node, "modulate:a", 0.0, 0.2)
tw.chain().tween_callback(node.queue_free)
# Screen shake
_shake_camera(5.0, 0.1)
func _spawn_death_particles(pos: Vector2, color: Color):
var particles = GPUParticles2D.new()
particles.global_position = pos
particles.emitting = true
particles.one_shot = true
particles.amount = 24
particles.lifetime = 0.6
var mat = ParticleProcessMaterial.new()
mat.spread = 180.0
mat.initial_velocity_min = 100.0
mat.initial_velocity_max = 280.0
mat.gravity = Vector3(0, 300, 0)
mat.damping_min = 2.0
mat.damping_max = 5.0
mat.scale_min = 2.0
mat.scale_max = 6.0
mat.color = color
particles.process_material = mat
get_tree().current_scene.add_child(particles)
get_tree().create_timer(1.5).timeout.connect(particles.queue_free)
func _shake_camera(intensity: float, duration: float):
var cam = get_viewport().get_camera_2d()
if cam and cam.has_method("shake"):
cam.shake(intensity, duration)
# camera_shake.gd — attach to Camera2D or add as child script
extends Camera2D
var _shake_intensity := 0.0
var _shake_decay := 0.0
func shake(intensity: float = 5.0, duration: float = 0.15):
_shake_intensity = intensity
_shake_decay = intensity / duration
func _process(delta):
if _shake_intensity > 0:
offset = Vector2(
randf_range(-_shake_intensity, _shake_intensity),
randf_range(-_shake_intensity, _shake_intensity)
)
_shake_intensity = maxf(_shake_intensity - _shake_decay * delta, 0.0)
else:
offset = offset.lerp(Vector2.ZERO, 10.0 * delta)
func pop_score(pos: Vector2, value: int, color: Color = Color.YELLOW):
var label = Label.new()
label.text = "+%d" % value
label.global_position = pos + Vector2(randf_range(-8, 8), -15)
label.add_theme_font_size_override("font_size", 18)
label.add_theme_color_override("font_color", color)
label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.6))
label.add_theme_constant_override("shadow_offset_x", 1)
label.add_theme_constant_override("shadow_offset_y", 1)
label.z_index = 100
get_tree().current_scene.add_child(label)
var tw = label.create_tween()
tw.set_parallel(true)
tw.tween_property(label, "position:y", label.position.y - 50, 0.7).set_ease(Tween.EASE_OUT)
tw.tween_property(label, "modulate:a", 0.0, 0.7).set_ease(Tween.EASE_IN)
tw.tween_property(label, "scale", Vector2(1.4, 1.4), 0.1)
tw.chain().tween_property(label, "scale", Vector2.ONE, 0.15)
tw.chain().tween_callback(label.queue_free)
func punch_ui(control: Control, scale: float = 1.2):
var tw = control.create_tween()
tw.tween_property(control, "scale", Vector2.ONE * scale, 0.08)
tw.tween_property(control, "scale", Vector2.ONE, 0.15).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_ELASTIC)
func hitstop(duration: float = 0.04):
Engine.time_scale = 0.05
var tw = get_tree().create_tween()
tw.tween_interval(duration)
tw.tween_callback(func(): Engine.time_scale = 1.0)
# On landing:
func _on_land():
if has_node("Visual"):
var tw = $Visual.create_tween()
$Visual.scale = Vector2(1.3, 0.7) # Squash
tw.tween_property($Visual, "scale", Vector2.ONE, 0.15).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_ELASTIC)
# Dust particles
_spawn_dust(global_position + Vector2(0, 8))
func _on_jump():
if has_node("Visual"):
var tw = $Visual.create_tween()
$Visual.scale = Vector2(0.7, 1.3) # Stretch
tw.tween_property($Visual, "scale", Vector2.ONE, 0.2).set_ease(Tween.EASE_OUT)
func _spawn_dust(pos: Vector2):
var p = GPUParticles2D.new()
p.global_position = pos
p.emitting = true
p.one_shot = true
p.amount = 6
p.lifetime = 0.4
var mat = ParticleProcessMaterial.new()
mat.spread = 60.0
mat.initial_velocity_min = 20.0
mat.initial_velocity_max = 50.0
mat.direction = Vector3(0, -1, 0)
mat.gravity = Vector3(0, 50, 0)
mat.scale_min = 2.0
mat.scale_max = 4.0
mat.color = Color(1, 1, 1, 0.2)
p.process_material = mat
get_tree().current_scene.add_child(p)
get_tree().create_timer(1.0).timeout.connect(p.queue_free)
func _add_trail():
var trail = Line2D.new()
trail.name = "Trail"
trail.width = 3.0
trail.default_color = Color(1, 1, 0.5, 0.5)
trail.top_level = true
trail.z_index = -1
# Gradient: bright at tip, fading behind
var grad = Gradient.new()
grad.set_color(0, Color(1, 1, 0.5, 0.0))
grad.set_color(1, Color(1, 1, 0.5, 0.6))
trail.gradient = grad
add_child(trail)
func _update_trail():
if has_node("Trail"):
$Trail.add_point(global_position)
while $Trail.get_point_count() > 12:
$Trail.remove_point(0)
func _muzzle_flash(pos: Vector2, dir: Vector2):
var flash = Node2D.new()
flash.global_position = pos
flash.rotation = dir.angle()
get_tree().current_scene.add_child(flash)
# Quick bright burst
var light = PointLight2D.new()
light.energy = 2.0
light.color = Color(1, 0.9, 0.5)
light.texture_scale = 0.3
flash.add_child(light)
var tw = flash.create_tween()
tw.tween_property(light, "energy", 0.0, 0.06)
tw.tween_callback(flash.queue_free)
func _build_atmosphere():
# Layer 1: Gradient shader background
var bg = ColorRect.new()
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.z_index = -100
if ResourceLoader.exists("res://shaders/gradient_bg.gdshader"):
var mat = ShaderMaterial.new()
mat.shader = load("res://shaders/gradient_bg.gdshader")
bg.material = mat
else:
bg.color = Color(0.04, 0.02, 0.1)
add_child(bg)
# Layer 2: Subtle animated grid
var grid = Node2D.new()
grid.name = "GridOverlay"
grid.z_index = -99
grid.set_script(load("res://scripts/effects/grid_bg.gd"))
add_child(grid)
# Layer 3: Floating ambient particles
var ambient = GPUParticles2D.new()
ambient.z_index = -98
ambient.amount = 30
ambient.lifetime = 6.0
var mat = ParticleProcessMaterial.new()
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
mat.emission_box_extents = Vector3(600, 400, 0)
mat.initial_velocity_min = 5.0
mat.initial_velocity_max = 15.0
mat.direction = Vector3(0, -1, 0)
mat.spread = 30.0
mat.scale_min = 1.0
mat.scale_max = 3.0
mat.color = Color(1, 1, 1, 0.04)
ambient.process_material = mat
add_child(ambient)
# Layer 4: Vignette overlay
var vignette = ColorRect.new()
vignette.set_anchors_preset(Control.PRESET_FULL_RECT)
vignette.z_index = 90
vignette.color = Color(0, 0, 0, 0)
vignette.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(vignette)
# scripts/effects/grid_bg.gd
extends Node2D
var grid_size := 64
var grid_color := Color(1, 1, 1, 0.03)
var area := 2000
func _draw():
for x in range(-area, area + 1, grid_size):
draw_line(Vector2(x, -area), Vector2(x, area), grid_color)
for y in range(-area, area + 1, grid_size):
draw_line(Vector2(-area, y), Vector2(area, y), grid_color)
func _build_polished_menu():
# Dark gradient bg (use shader if available)
var bg = ColorRect.new()
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
if ResourceLoader.exists("res://shaders/gradient_bg.gdshader"):
var mat = ShaderMaterial.new()
mat.shader = load("res://shaders/gradient_bg.gdshader")
bg.material = mat
else:
bg.color = Color(0.04, 0.02, 0.1)
add_child(bg)
# Floating particles behind menu
var ambient = GPUParticles2D.new()
ambient.position = Vector2(576, 324)
ambient.z_index = 1
ambient.amount = 20
ambient.lifetime = 8.0
var pmat = ParticleProcessMaterial.new()
pmat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
pmat.emission_box_extents = Vector3(600, 400, 0)
pmat.initial_velocity_min = 3.0
pmat.initial_velocity_max = 10.0
pmat.direction = Vector3(0, -1, 0)
pmat.scale_min = 1.0
pmat.scale_max = 3.0
pmat.color = Color(1, 1, 1, 0.04)
ambient.process_material = pmat
add_child(ambient)
var center = CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.z_index = 10
add_child(center)
var vbox = VBoxContainer.new()
vbox.add_theme_constant_override("separation", 24)
center.add_child(vbox)
# Title with glow
var title = Label.new()
title.text = "GAME TITLE"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 56)
title.add_theme_color_override("font_color", Color(0, 0.9, 1.0))
title.add_theme_color_override("font_shadow_color", Color(0, 0.5, 1.0, 0.4))
title.add_theme_constant_override("shadow_offset_x", 0)
title.add_theme_constant_override("shadow_offset_y", 3)
vbox.add_child(title)
# Animated title pulse
var tw = title.create_tween().set_loops()
tw.tween_property(title, "modulate:a", 0.7, 1.5).set_ease(Tween.EASE_IN_OUT)
tw.tween_property(title, "modulate:a", 1.0, 1.5).set_ease(Tween.EASE_IN_OUT)
# Subtitle
var subtitle = Label.new()
subtitle.text = "Press PLAY to begin"
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
subtitle.add_theme_font_size_override("font_size", 16)
subtitle.add_theme_color_override("font_color", Color(1, 1, 1, 0.5))
vbox.add_child(subtitle)
var spacer = Control.new()
spacer.custom_minimum_size.y = 30
vbox.add_child(spacer)
# Styled buttons (use godot-assets pattern)
for btn_data in [["PLAY", _on_play], ["QUIT", _on_quit]]:
var btn = _create_styled_button(btn_data[0], Color(0, 0.6, 1.0), btn_data[1])
vbox.add_child(btn)
func _create_styled_button(text: String, accent: Color, callback: Callable) -> Button:
var btn = Button.new()
btn.text = text
btn.custom_minimum_size = Vector2(240, 56)
btn.add_theme_font_size_override("font_size", 20)
btn.pressed.connect(callback)
var normal = StyleBoxFlat.new()
normal.bg_color = accent.darkened(0.3)
normal.corner_radius_top_left = 8
normal.corner_radius_top_right = 8
normal.corner_radius_bottom_left = 8
normal.corner_radius_bottom_right = 8
normal.border_width_bottom = 3
normal.border_color = accent.darkened(0.5)
normal.content_margin_top = 12
normal.content_margin_bottom = 12
btn.add_theme_stylebox_override("normal", normal)
var hover = normal.duplicate()
hover.bg_color = accent.darkened(0.15)
btn.add_theme_stylebox_override("hover", hover)
var pressed = normal.duplicate()
pressed.bg_color = accent.darkened(0.45)
pressed.border_width_bottom = 1
pressed.content_margin_top = 14
btn.add_theme_stylebox_override("pressed", pressed)
btn.add_theme_color_override("font_color", Color.WHITE)
# Hover animation
btn.pivot_offset = btn.custom_minimum_size / 2
btn.mouse_entered.connect(func():
var t = btn.create_tween()
t.tween_property(btn, "scale", Vector2(1.05, 1.05), 0.1)
)
btn.mouse_exited.connect(func():
var t = btn.create_tween()
t.tween_property(btn, "scale", Vector2.ONE, 0.1)
)
return btn
func _on_play():
var overlay = ColorRect.new()
overlay.color = Color(0, 0, 0, 0)
overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
overlay.z_index = 200
add_child(overlay)
var tw = create_tween()
tw.tween_property(overlay, "color:a", 1.0, 0.4)
tw.tween_callback(func(): get_tree().change_scene_to_file("res://scenes/main.tscn"))
func _on_quit():
get_tree().quit()
# Fade to black → change scene → fade in
func transition_to_scene(scene_path: String, duration: float = 0.4):
var overlay = ColorRect.new()
overlay.color = Color(0, 0, 0, 0)
overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
overlay.z_index = 200
overlay.mouse_filter = Control.MOUSE_FILTER_STOP
get_tree().current_scene.add_child(overlay)
var tw = create_tween()
tw.tween_property(overlay, "color:a", 1.0, duration)
tw.tween_callback(func():
get_tree().change_scene_to_file(scene_path)
)
# Fade-in happens in the new scene's _ready()
# Call in new scene's _ready() to fade in:
func _fade_in_scene(duration: float = 0.3):
var overlay = ColorRect.new()
overlay.color = Color(0, 0, 0, 1)
overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
overlay.z_index = 200
overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(overlay)
var tw = create_tween()
tw.tween_property(overlay, "color:a", 0.0, duration)
tw.tween_callback(overlay.queue_free)
# During Phase 5, create shader files if they don't exist
func _ensure_shaders():
var shaders = {
"res://shaders/gradient_bg.gdshader": _gradient_bg_shader(),
"res://shaders/hit_flash.gdshader": _hit_flash_shader(),
"res://shaders/dissolve.gdshader": _dissolve_shader(),
"res://shaders/glow_outline.gdshader": _glow_outline_shader(),
}
var dir = DirAccess.open("res://")
if not dir.dir_exists("shaders"):
dir.make_dir("shaders")
for path in shaders:
if not ResourceLoader.exists(path):
var f = FileAccess.open(path, FileAccess.WRITE)
f.store_string(shaders[path])
f.close()
# Shader source strings — see godot-assets skill for full implementations