Godot 4 · 3D · GDScript · Game Systems

Teetering
System Documentation

// personal reference · 3D edge detection · ledge fall · bubble indicator

01 What is Teetering

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.

Design Goal Player never falls accidentally while walking. Running off an edge is intentional. Teetering gives player a moment to decide — jump down or back away.

// When teetering activates

conditionteeter active?
player grounded + walking + moving toward edgeYES ✓
player standing still near edgeNO ✗
player running toward edgeNO — falls off (switchable)
player airborne / fallingNO ✗
TEETER_ENABLED = falseNO ✗

02 Settings & Toggles SWITCHABLE

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

# =============================================

03 States

WALKING
normal movement, rays checking ahead
default state
TEETERING
at edge, bubble shows, movement stopped, waiting for input
edge detected while walking
FALLING
airborne, no teeter detection, Godot handles collision
confirm pressed / running off edge
LANDING
just landed, calculate fall distance, call damage if enabled
grounded after falling
Important Teetering NEVER activates during FALLING state. Check is_grounded before any edge detection logic.

04 Ray Detection — 8 Rays SYSTEM

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.

     
     
     
8 rays around character feet · each points downward at slight forward angle

// Only check rays matching movement direction

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

05 Slope Threshold

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
Tuning MAX_SLOPE_HEIGHT Start at 16px and test. Too small = teeters on gentle slopes. Too large = misses real edges. Ask your artist the maximum slope height in the game and set threshold just above that.

06 Bubble System REUSABLE IN CUTSCENE

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
Reuse in Cutscene This same show_bubble() / hide_bubble() can be called from your cutscene expression command. One system, two uses.

07 Fall Tracking

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"

08 Fall Damage — External System

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
Workflow Play the game with FALL_DAMAGE_ENABLED = false. Watch the print output for fall distances. Use those real numbers to fill in the damage thresholds above. No guessing needed.

09 Full Teetering Code SYSTEM

# 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

10 Summary

Build Order Suggestion Get 8 rays working first. Test edge detection without teeter state. Then add teeter state + bubble. Fall tracking and damage last.