// personal reference · 3D edge detection · ledge fall · bubble indicator
Teetering is a ledge safety mechanic from Wild Arms — when a player walks toward an edge slowly, the character stops and shows a bubble indicator instead of falling. The player must confirm to fall down intentionally.
| condition | teeter active? |
|---|---|
| player grounded + walking + moving toward edge | YES ✓ |
| player standing still near edge | NO ✗ |
| player running toward edge | NO — falls off (switchable) |
| player airborne / falling | NO ✗ |
| TEETER_ENABLED = false | NO ✗ |
All settings live at the top of the script. Change these without touching logic code. Adjust per scene as needed.
# =============================================
# TEETERING SYSTEM SETTINGS
# =============================================
# Master switch — disable teetering entirely for action sections
var TEETER_ENABLED: bool = true
# Show bubble indicator when teetering
var BUBBLE_ENABLED: bool = true
# Running toward edge = fall off immediately (no teeter)
# set false = running also triggers teeter
var RUN_FALLS_OFF: bool = true
# Enable optional extra cancel buttons (beside move backward)
var OPTIONAL_CANCEL_ENABLED: bool = true
# Max height difference (pixels) to treat as slope vs edge
# tune based on your art — bigger = less sensitive
var MAX_SLOPE_HEIGHT: float = 16.0
# Enable fall damage — calls DamageSystem.calculate_fall_damage()
var FALL_DAMAGE_ENABLED: bool = false
# Restart player position on fall (for dungeons)
var RESTART_ON_FALL: bool = false
# =============================================
8 raycasts positioned around character feet, each pointing downward at a slight forward angle. Free movement (non-tile) requires 8 rays to cover all walk directions.
No need to check all 8 every frame. Filter rays using dot product against movement direction — only rays roughly facing the same direction as movement are checked.
# setup 8 raycasts in _ready() — RayCast3D for 3D world
var rays: Array = []
# 8 directions as Vector3 (X/Z plane, Y is up in 3D)
var RAY_DIRS = [
Vector3(1,0,0), Vector3(1,0,1).normalized(),
Vector3(0,0,1), Vector3(-1,0,1).normalized(),
Vector3(-1,0,0), Vector3(-1,0,-1).normalized(),
Vector3(0,0,-1), Vector3(1,0,-1).normalized()
]
func _ready():
for dir in RAY_DIRS:
var ray = RayCast3D.new()
# forward + downward in 3D (Y is down offset)
ray.target_position = (dir * 0.3) + Vector3(0, -0.8, 0)
ray.enabled = true
add_child(ray)
rays.append(ray)
# check only rays matching movement direction
func is_edge_ahead(move_dir: Vector3) -> bool:
for ray in rays:
var ray_dir = ray.target_position.normalized()
# dot product > 0.5 means ray faces same direction as movement
if ray_dir.dot(move_dir) > 0.5:
if check_ray_is_edge(ray):
return true
return false
If a ray hits ground but at a very different height, it's a cliff edge — not just a slope. Threshold is controlled by MAX_SLOPE_HEIGHT in settings.
func check_ray_is_edge(ray: RayCast3D) -> bool:
# ray hits nothing = definite edge
if not ray.is_colliding():
return true
# ray hits ground — check height difference (Y axis in 3D)
var hit_y = ray.get_collision_point().y
var char_y = global_position.y
var height_diff = char_y - hit_y # positive = drop below character
# bigger than threshold = cliff edge, not slope
return height_diff > MAX_SLOPE_HEIGHT
Simple sprite above character head. Always faces camera using global_rotation = 0. Works for any character — no per-character art needed. Reusable for cutscene expressions later.
# Bubble node setup in 3D scene:
# Character (CharacterBody3D)
# └── BubbleAnchor (Node3D — positioned above head)
# └── BubbleSprite (Sprite3D — set Billboard = Enabled in Inspector)
# Billboard mode = always faces camera automatically ✅
# Bubble sprites — load your own images
var bubble_textures = {
"exclamation": preload("res://bubbles/exclamation.png"),
"question": preload("res://bubbles/question.png"),
"arrow_down": preload("res://bubbles/arrow_down.png"),
}
func show_bubble(type: String):
if not BUBBLE_ENABLED: return
$BubbleAnchor/BubbleSprite.texture = bubble_textures[type]
$BubbleAnchor.visible = true
func hide_bubble():
$BubbleAnchor.visible = false
# No manual rotation needed in 3D!
# Sprite3D Billboard property handles camera facing automatically
# Set in Inspector: Sprite3D → Billboard → Enabled
Store position before fall and calculate fall distance on landing. Always print for game design reference — decide damage values later without changing code.
# tracking vars — all Vector3 in 3D
var position_before_fall: Vector3 = Vector3.ZERO
var fall_start_y: float = 0.0
var last_fall_distance: float = 0.0
# called when player confirms fall or runs off edge
func start_fall():
position_before_fall = global_position # Vector3 — store for restart
fall_start_y = global_position.y # Y is vertical axis in 3D
state = "falling"
hide_bubble()
# called when player lands (is_on_floor() becomes true)
func on_land():
last_fall_distance = fall_start_y - global_position.y # Y drops on fall in 3D
# always print for game design reference
print("[FALL] distance: ", last_fall_distance, " units | from: ", position_before_fall)
if FALL_DAMAGE_ENABLED:
DamageSystem.calculate_fall_damage(last_fall_distance)
if RESTART_ON_FALL:
global_position = position_before_fall
state = "walking"
Teetering system does NOT calculate damage. It only passes fall distance to an external function. Implement damage logic separately when game design is final.
# DamageSystem.gd — implement later when game design is final
extends Node
func calculate_fall_damage(fall_distance: float):
# use last_fall_distance printed values to decide thresholds
# example structure — fill values when ready:
if fall_distance < 50:
pass # no damage
elif fall_distance < 150:
pass # light damage
elif fall_distance < 300:
pass # heavy damage
else:
pass # instant death
# Teetering.gd — attach to player character (3D)
extends CharacterBody3D
# =============================================
# SETTINGS
# =============================================
var TEETER_ENABLED: bool = true
var BUBBLE_ENABLED: bool = true
var RUN_FALLS_OFF: bool = true
var OPTIONAL_CANCEL_ENABLED: bool = true
var MAX_SLOPE_HEIGHT: float = 0.5 # 3D units, tune to your art
var FALL_DAMAGE_ENABLED: bool = false
var RESTART_ON_FALL: bool = false
# =============================================
# INTERNAL VARS
# =============================================
var state: String = "walking"
var rays: Array = []
var position_before_fall: Vector3 = Vector3.ZERO
var fall_start_y: float = 0.0
var last_fall_distance: float = 0.0
var WALK_SPEED: float = 4.0
var RUN_SPEED: float = 8.0
# 8 directions on X/Z plane
var RAY_DIRS = [
Vector3(1,0,0), Vector3(1,0,1).normalized(),
Vector3(0,0,1), Vector3(-1,0,1).normalized(),
Vector3(-1,0,0), Vector3(-1,0,-1).normalized(),
Vector3(0,0,-1), Vector3(1,0,-1).normalized()
]
func _ready():
# build 8 RayCast3D nodes
for dir in RAY_DIRS:
var ray = RayCast3D.new()
ray.target_position = (dir * 0.3) + Vector3(0, -0.8, 0)
ray.enabled = true
add_child(ray)
rays.append(ray)
func _physics_process(delta):
match state:
"walking": process_walking(delta)
"teetering": process_teetering()
"falling": process_falling()
"landing": on_land()
func process_walking(delta):
if not TEETER_ENABLED: return
if not is_on_floor(): return
# input is Vector2, convert to Vector3 X/Z movement
var input2d = Input.get_vector("left", "right", "up", "down")
if input2d == Vector2.ZERO: return
var move_dir3d = Vector3(input2d.x, 0, input2d.y).normalized()
var is_running = Input.is_action_pressed("run")
if is_edge_ahead(move_dir3d):
if is_running and RUN_FALLS_OFF:
start_fall()
else:
trigger_teeter()
func process_teetering():
if Input.is_action_just_pressed("confirm"):
start_fall()
return
var input2d = Input.get_vector("left", "right", "up", "down")
if input2d != Vector2.ZERO:
cancel_teeter()
return
if OPTIONAL_CANCEL_ENABLED:
if Input.is_action_just_pressed("cancel"):
cancel_teeter()
func process_falling():
if is_on_floor():
state = "landing"
func trigger_teeter():
state = "teetering"
velocity = Vector3.ZERO # stop movement — Vector3 in 3D
show_bubble("exclamation")
func cancel_teeter():
state = "walking"
hide_bubble()
func start_fall():
position_before_fall = global_position # Vector3
fall_start_y = global_position.y
state = "falling"
hide_bubble()
func on_land():
last_fall_distance = fall_start_y - global_position.y # Y drops on fall
print("[FALL] distance: ", last_fall_distance, " units | from: ", position_before_fall)
if FALL_DAMAGE_ENABLED:
DamageSystem.calculate_fall_damage(last_fall_distance)
if RESTART_ON_FALL:
global_position = position_before_fall
state = "walking"
# Ray helpers
func is_edge_ahead(move_dir: Vector2) -> bool:
for ray in rays:
var ray_dir = ray.target_position.normalized()
if ray_dir.dot(move_dir) > 0.5:
if check_ray_is_edge(ray):
return true
return false
func check_ray_is_edge(ray: RayCast2D) -> bool:
if not ray.is_colliding(): return true
var height_diff = ray.get_collision_point().y - global_position.y
return height_diff > MAX_SLOPE_HEIGHT
# Bubble helpers
func show_bubble(type: String):
if not BUBBLE_ENABLED: return
$BubbleAnchor/BubbleSprite.texture = load("res://bubbles/" + type + ".png")
$BubbleAnchor.visible = true
func hide_bubble():
$BubbleAnchor.visible = false