diff --git a/audio/bus_layout.tres b/audio/bus_layout.tres
new file mode 100644
index 0000000..356d5e4
--- /dev/null
+++ b/audio/bus_layout.tres
@@ -0,0 +1,15 @@
+[gd_resource type="AudioBusLayout" format=3 uid="uid://csk12vyidwnew"]
+
+[resource]
+bus/1/name = &"Sound Effects"
+bus/1/solo = false
+bus/1/mute = false
+bus/1/bypass_fx = false
+bus/1/volume_db = 0.0
+bus/1/send = &"Master"
+bus/2/name = &"Music"
+bus/2/solo = false
+bus/2/mute = false
+bus/2/bypass_fx = false
+bus/2/volume_db = 0.0
+bus/2/send = &"Master"
diff --git a/characters/base/base_character.tscn b/characters/base/base_character.tscn
new file mode 100644
index 0000000..c5694d3
--- /dev/null
+++ b/characters/base/base_character.tscn
@@ -0,0 +1,74 @@
+[gd_scene load_steps=29 format=3 uid="uid://blpbgwklc21k5"]
+
+[ext_resource type="Script" uid="uid://jshmfmeoj28y" path="res://characters/base/character.gd" id="1_f78fl"]
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="2_cchsv"]
+[ext_resource type="AudioStream" uid="uid://hv8sb7mxhcnb" path="res://audio/land.ogg" id="2_ggkgm"]
+[ext_resource type="Script" uid="uid://dl5vblkrydr4q" path="res://util/property_save_restore_stack.gd" id="2_lii0y"]
+[ext_resource type="Resource" uid="uid://sqqxxbj4duf4" path="res://characters/base/template_csp_idle.tres" id="3_4vv4m"]
+[ext_resource type="Script" uid="uid://bafxukojlafvh" path="res://util/state_machine.gd" id="3_l7dgf"]
+[ext_resource type="Resource" uid="uid://5mv3ctktmsbm" path="res://characters/base/template_csp_walk.tres" id="4_0mnbh"]
+[ext_resource type="Resource" uid="uid://bjgfhx1i6g8pu" path="res://characters/base/template_csp_run.tres" id="5_lyvph"]
+[ext_resource type="Resource" uid="uid://bvruotly1ghl8" path="res://characters/base/template_csp_sprint.tres" id="6_aigsb"]
+[ext_resource type="Resource" uid="uid://da512s5hs7ly" path="res://characters/base/template_csp_jump.tres" id="7_q5w5h"]
+[ext_resource type="Resource" uid="uid://bhq36aidyidqr" path="res://characters/base/template_csp_fall.tres" id="8_wjwdx"]
+[ext_resource type="Resource" uid="uid://cmk3r1rli555v" path="res://characters/base/template_csp_skid.tres" id="9_e1qjb"]
+[ext_resource type="Resource" uid="uid://btu3efdwlpr7k" path="res://characters/base/template_csp_wall_slide.tres" id="10_5e0lb"]
+[ext_resource type="Resource" uid="uid://caskx8dmdd3h5" path="res://characters/base/template_csp_hang.tres" id="11_euyh3"]
+[ext_resource type="Resource" uid="uid://blflaoqntbe7w" path="res://characters/base/template_csp_pull_up.tres" id="12_5etud"]
+[ext_resource type="Resource" uid="uid://bhwoe6hvwy4ak" path="res://characters/base/template_csp_hit.tres" id="13_5d22g"]
+[ext_resource type="Resource" uid="uid://oi0fr0o3ieis" path="res://characters/base/template_csp_defeat.tres" id="14_j1rrh"]
+[ext_resource type="Resource" uid="uid://cjqc2nyywenfk" path="res://characters/base/template_csp_victory1.tres" id="15_hhowv"]
+[ext_resource type="Resource" uid="uid://bss0mt60m7lep" path="res://characters/base/template_csp_victory2.tres" id="16_0pfk6"]
+[ext_resource type="Resource" uid="uid://dlenj6oro0pfn" path="res://characters/base/template_csp_pick_up.tres" id="17_0mnbh"]
+[ext_resource type="Resource" uid="uid://c2x7tc3irusqo" path="res://characters/base/template_csp_put_down.tres" id="18_lyvph"]
+[ext_resource type="Resource" uid="uid://cwxqc1hw103ns" path="res://characters/base/template_csp_idle_while_holding.tres" id="19_euyh3"]
+[ext_resource type="Resource" uid="uid://dn6h1cdnxolmk" path="res://characters/base/template_csp_walk_while_holding.tres" id="20_5etud"]
+[ext_resource type="Resource" uid="uid://c4u68hfaaoeoe" path="res://characters/base/template_csp_run_while_holding.tres" id="21_5d22g"]
+[ext_resource type="Resource" uid="uid://b5sb0w2ex8hyn" path="res://characters/base/template_csp_fall_while_holding.tres" id="22_j1rrh"]
+[ext_resource type="Resource" uid="uid://cgx3p61bbw6sw" path="res://characters/base/template_csp_jump_while_holding.tres" id="23_hhowv"]
+[ext_resource type="Resource" uid="uid://dc346050qtltb" path="res://characters/base/template_csp_swim.tres" id="24_0pfk6"]
+[ext_resource type="Resource" uid="uid://mgefvwuayfk4" path="res://characters/base/template_csp_swim_while_holding.tres" id="25_hpxkk"]
+
+[node name="BaseCharacter" type="RigidBody3D" node_paths=PackedStringArray("_audio_player", "_property_save_restore_stack", "_state_machine")]
+mass = 60.0
+script = ExtResource("1_f78fl")
+_audio_player = NodePath("AudioStreamPlayer3D")
+_property_save_restore_stack = NodePath("PropertySaveRestoreStack")
+_state_machine = NodePath("StateMachine")
+_state_properties = Dictionary[StringName, ExtResource("2_cchsv")]({
+&"defeat": ExtResource("14_j1rrh"),
+&"fall": ExtResource("8_wjwdx"),
+&"fall-while-holding": ExtResource("22_j1rrh"),
+&"hang": ExtResource("11_euyh3"),
+&"hit": ExtResource("13_5d22g"),
+&"idle": ExtResource("3_4vv4m"),
+&"idle-while-holding": ExtResource("19_euyh3"),
+&"jump": ExtResource("7_q5w5h"),
+&"jump-while-holding": ExtResource("23_hhowv"),
+&"pick-up": ExtResource("17_0mnbh"),
+&"pull-up": ExtResource("12_5etud"),
+&"put-down": ExtResource("18_lyvph"),
+&"run": ExtResource("5_lyvph"),
+&"run-while-holding": ExtResource("21_5d22g"),
+&"skid": ExtResource("9_e1qjb"),
+&"sprint": ExtResource("6_aigsb"),
+&"swim": ExtResource("24_0pfk6"),
+&"swim-while-holding": ExtResource("25_hpxkk"),
+&"victory1": ExtResource("15_hhowv"),
+&"victory2": ExtResource("16_0pfk6"),
+&"walk": ExtResource("4_0mnbh"),
+&"walk-while-holding": ExtResource("20_5etud"),
+&"wall-slide": ExtResource("10_5e0lb")
+})
+_landing_sound = ExtResource("2_ggkgm")
+metadata/_custom_type_script = "uid://jshmfmeoj28y"
+
+[node name="AudioStreamPlayer3D" type="AudioStreamPlayer3D" parent="."]
+
+[node name="PropertySaveRestoreStack" type="Node" parent="."]
+script = ExtResource("2_lii0y")
+metadata/_custom_type_script = "uid://dl5vblkrydr4q"
+
+[node name="StateMachine" type="Node" parent="."]
+script = ExtResource("3_l7dgf")
+metadata/_custom_type_script = "uid://bafxukojlafvh"
diff --git a/characters/base/character.gd b/characters/base/character.gd
new file mode 100644
index 0000000..174969f
--- /dev/null
+++ b/characters/base/character.gd
@@ -0,0 +1,1260 @@
+class_name Character extends RigidBody3D
+## "Lifelike" physics body suitable for player characters, NPCs, and enemies.
+
+#region Exported Properties
+
+@export_group("Helper Nodes")
+## Animation player used to play appropriate animation for current state.
+@export var _anim_player: AnimationPlayer
+## Body collider. Should be a capsule. Script may change height and radius.
+@export var _collider: CollisionShape3D
+## Player for character-associated sounds such as footsteps and vocalizations.
+@export var _audio_player: AudioStreamPlayer3D
+## Needed for saving and restoring properties between state transitions.
+@export var _property_save_restore_stack: PropertySaveRestoreStack
+## Needed for keeping track of current state and state transitions.
+@export var _state_machine: StateMachine
+
+@export_group("States")
+## How being in each possible state should influence the character.
+@export var _state_properties: Dictionary[StringName, CharacterStateProperties]
+## State the character should be in when the scene starts.
+@export var _initial_state: StringName = &'idle'
+
+@export_group("Physics")
+
+@export_subgroup("On Ground")
+## Refuse forward input if moving faster than this while grounded.
+@export var _top_ground_speed: float = 20.0
+## Magnitude of manual forward acceleration while grounded.
+@export var _ground_acceleration: float = 15.0
+## Magnitude of automatic deceleration while grounded.
+@export var _ground_auto_deceleration: float = 10.0
+## Magnitude of manual deceleration while grounded.
+@export var _ground_manual_deceleration: float = 30.0
+## Magnitude of centrifugal acceleration at top ground speed.
+## [br][br]
+## Character will be subject to a centrifugal force
+## proportional to current ground speed.
+## It is at its maximum at top ground speed,
+## and at zero when the character is at rest.
+## While this is by no means physically realistic
+## (in real life, there is no such thing as centrifugal force
+## in an inertial frame of reference),
+## it permits genre-staple parkour techniques
+## such as being able to run up walls and on ceilings
+## if moving fast enough.
+## [br][br]
+## The centrifugal acceleration applied may actually exceed this amount,
+## but only whenever the character's ground speed exceeds its top ground speed.
+@export var _max_centrifugal_acceleration: float = 20.0
+## Jump height if the jump button is tapped and immediately released.
+## [br][br]
+## This assumes the character is jumping from rest position on level ground
+## under standard gravity. When this assumption does not hold,
+## actual jump height may vary, but will vary in a physically sensible way.
+## Put another way, this property is used to compute the magnitudes
+## of both the initial jump impact and the vertical deceleration to apply
+## to truncate the jump if the button is released, and, once computed,
+## it is these magnitudes, not the jump height itself,
+## which remain fixed across all situations.
+@export var _min_jump_height: float = 1.0
+## Jump height if the jump button is held until the peak of the jump.
+## [br][br]
+## This assumes the character is jumping from rest position on level ground
+## under standard gravity. When this assumption does not hold,
+## actual jump height may vary, but will vary in a physically sensible way.
+## Put another way, this property is used to compute the magnitudes
+## of both the initial jump impact and the vertical deceleration to apply
+## to truncate the jump if the button is released, and, once computed,
+## it is these magnitudes, not the jump height itself,
+## which remain fixed across all situations.
+@export var _max_jump_height: float = 4.0
+## Maximum angular distance in radians to normal of next ground.
+## [br][br]
+## That is to say, if the angle between the current ground normal
+## and the normal of some contact surface exceeds this many radians,
+## then that contact surface is considered a wall or ceiling;
+## otherwise, it is ground, and can be walked upon.
+@export var _max_slope_angle: float = 1.0
+## Friction when standing still.
+## [br][br]
+## Friction at or above top speed is always 0.0.
+## Friction at any speed between 0.0 and top speed is decided
+## by interpolating between the value given here and 0.0, respectively.
+@export var _max_friction: float = 0.25
+## Audio stream to play when landing from a fall or jump.
+@export var _landing_sound: AudioStream = null
+## Volume to play landing sound when landing from a fall or jump.
+@export var _landing_sound_volume_db: float = 0.0
+
+@export_subgroup("In Air")
+## Refuse forward input if moving faster than this while not grounded.
+@export var _top_air_speed: float = 30.0
+## Magnitude of manual forward acceleration while not grounded.
+@export var _air_acceleration: float = 5.0
+## Magnitude of automatic deceleration while not grounded.
+@export var _air_auto_deceleration: float = 5.0
+## Magnitude of manual deceleration while not grounded.
+@export var _air_manual_deceleration: float = 10.0
+## Lerp speed for gradually turning right-side-up while not grounded.
+@export var _air_righting_lerp_speed: float = 0.5
+
+@export_group("Combat")
+## Amount of health character possesses when undamaged.
+@export var max_health: float = 100.0
+## Damage dealt is multiplied by this.
+@export var attack_power: float = 1.0
+## Damage taken is divided by this.
+@export var defense_power: float = 1.0
+## Knockback dealt is multiplied by this.
+@export var knockback: float = 1.0
+## Knockback taken is divided by this.
+@export var knockback_resistance: float = 1.0
+
+#endregion
+
+#region Private Properties
+
+var _state_handler_descriptors: Dictionary[StringName, Variant]
+var _current_state_properties: CharacterStateProperties
+var _state_coyote_time: float = 0.0
+var _state_time_elapsed: float = 0.0
+var _air_time_elapsed: float = 0.0
+var _grounded := false
+var _touching_wall := false
+var _body_state: PhysicsDirectBodyState3D
+var _teleport_requested := false
+var _teleport_target := Vector3.ZERO
+var _freeze_lerp_requested := false
+var _freeze_lerp_target := Vector3.ZERO
+var _carrying: Node3D = null
+
+#endregion
+
+#region Public Properties
+
+## Direction facing away from what we currently think is the ground.
+## [br][br]
+## While the character is actually standing on a surface,
+## this is the contact normal of that surface.
+## Otherwise, this starts off at its last known value,
+## and is gradually interpolated toward the direction that opposes gravity.
+var ground_normal := Vector3.UP
+## Direction facing away from what we currently think is the wall.
+## [br][br]
+## Unlike ground_normal, this property has no meaning
+## if there is not currently an actual wall.
+var wall_normal := Vector3.ZERO
+## Current state of the state machine.
+## [br][br]
+## Attempting to set this property to the name of an unimplemented state
+## will fail with an error message.
+## [br][br]
+## Attempting to set this property while coyote time is active
+## will fail silently. Though it will not work, attempting to do it
+## is not to be considered an error; rather, the caller is expected
+## to repeat the attempt for as long as the state transition condition
+## remains satisfied, and the assignment will succeed once coyote time expires.
+var state: StringName:
+	get(): return _state_machine.state
+	set(value): change_state(value)
+## Local-space direction the character "wants" / is "trying" to move in.
+var impetus := Vector3.ZERO:
+	set(value):
+		if value.is_zero_approx():
+			impetus = Vector3.ZERO
+		else:
+			impetus = value.normalized()
+## World-space direction the character "wants" / is "trying" to move in.
+var global_impetus: Vector3:
+	get():
+		var gi := global_transform*impetus - global_position
+		if gi.is_zero_approx():
+			return Vector3.ZERO
+		else:
+			return gi.normalized()
+	set(value):
+		impetus = (global_position + value)*global_transform
+## Whether the character is "trying" to jump.
+var jump_impetus := false
+## Whether the character is "trying" to perform their primary action.
+var action1_impetus := false
+## Whether the character is "trying" to perform their secondary action.
+var action2_impetus := false
+## Collision shape height.
+var height: float:
+	get():
+		if _current_state_properties.collider_horizontal:
+			return 2.0*_collider.shape.radius
+		else:
+			return _collider.shape.height
+	set(value):
+		if _current_state_properties.collider_horizontal:
+			_collider.shape.radius = value/2.0
+		else:
+			_collider.shape.height = value
+		_collider.position.y = value/2.0
+		_audio_player.position.y = _collider.position.y
+## Collision shape diameter.
+var width: float:
+	get():
+		if _current_state_properties.collider_horizontal:
+			return _collider.shape.height
+		else:
+			return 2.0*_collider.shape.radius
+	set(value):
+		if _current_state_properties.collider_horizontal:
+			_collider.shape.height = value
+		else:
+			_collider.shape.radius = value/2.0
+## Character's current health.
+## [br][br]
+## Setting this variable to a positive value lower than its previous value
+## force-changes state to hit.
+## Setting it at or below 0.0 force-changes state to defeat.
+## Trying to set it to a value higher than max_health
+## will instead set it to max_health.
+## [br][br]
+## Normally you want to deal damage using the take_damage function,
+## as it also handles knockback, which directly invoking this setter does not.
+## However, the setter is suitable for custom knockback handling,
+## damage without knockback, and healing.
+var health: float:
+	set(value):
+		if value >= max_health:
+			value = max_health
+		elif value <= 0.0:
+			force_change_state(&'defeat')
+		elif value < health && !state_uninterruptible():
+			force_change_state(&'hit')
+		health = value
+## Object that the character is carrying if any, else null.
+var carrying: Node3D:
+	get():
+		return _carrying
+	set(value):
+		_pick_up_without_animation(value)
+
+#endregion
+
+#region Overrides
+
+func _ready() -> void:
+	_enforce_certain_properties()
+	_connect_to_state_machine()
+	force_change_state(_initial_state)
+
+func _exit_tree() -> void:
+	_disconnect_from_state_machine()
+	request_ready()
+
+func _integrate_forces(body_state: PhysicsDirectBodyState3D) -> void:
+	_body_state = body_state
+	_update_timers()
+	_update_anim_speed()
+	_check_contacts()
+	_reorient()
+	_handle_teleport_request()
+
+func _process(delta: float) -> void:
+	_handle_freeze_lerp_request(delta)
+
+#endregion
+
+#region State-Agnostic Private Methods
+
+func _update_timers() -> void:
+	if _state_coyote_time > 0.0:
+		_state_coyote_time -= _body_state.step
+	_state_time_elapsed += _body_state.step
+	if is_really_grounded():
+		_air_time_elapsed = 0.0
+	else:
+		_air_time_elapsed += _body_state.step
+
+func _update_anim_speed() -> void:
+	_anim_player.speed_scale = (
+		_current_state_properties.animation_base_speed + (
+			_current_state_properties.animation_speedup_with_velocity *
+			linear_velocity.length()
+		)
+	)
+
+func _handle_teleport_request() -> void:
+	if _teleport_requested:
+		global_position = _teleport_target
+		_teleport_requested = false
+
+func _handle_freeze_lerp_request(delta: float) -> void:
+	if freeze && _freeze_lerp_requested:
+		global_position = lerp(
+			global_position,
+			_freeze_lerp_target,
+			1.0 - 0.01**delta
+		)
+		if (global_position - _freeze_lerp_target).length() < height*0.03125:
+			global_position = _freeze_lerp_target
+			_freeze_lerp_requested = false
+
+func _connect_to_state_machine() -> void:
+	_state_handler_descriptors = _make_state_handler_descriptors()
+	_state_machine.connect_handlers(_state_handler_descriptors)
+	_state_machine.state_changed.connect(_on_state_changed)
+
+func _disconnect_from_state_machine() -> void:
+	_state_machine.disconnect_handlers(_state_handler_descriptors)
+	_state_machine.state_changed.disconnect(_on_state_changed)
+
+func _enforce_certain_properties() -> void:
+	if _collider.shape is CapsuleShape3D:
+		_collider.shape = _collider.shape.duplicate()
+	else:
+		_collider.shape = CapsuleShape3D.new()
+	_audio_player.bus = &'Sound Effects'
+	_property_save_restore_stack.target = self
+	if physics_material_override:
+		physics_material_override = physics_material_override.duplicate()
+	else:
+		physics_material_override = PhysicsMaterial.new()
+	physics_material_override.friction = 0.0
+	can_sleep = false
+	lock_rotation = true
+	freeze_mode = FREEZE_MODE_STATIC
+	custom_integrator = false
+	continuous_cd = true
+	contact_monitor = true
+	if max_contacts_reported < 6:
+		max_contacts_reported = 6
+	collision_priority = 2.0
+	health = max_health
+
+func _on_state_changed(state_name: StringName) -> void:
+	# Empty out the property save/restore stack,
+	# restoring all saved properties along the way.
+	while !_property_save_restore_stack.is_empty():
+		_property_save_restore_stack.pop()
+	# If there is a defined properties profile for this state:
+	if state_name in _state_properties:
+		_current_state_properties = _state_properties[state_name]
+		# Set coyote timer.
+		if _current_state_properties.use_coyote_time:
+			_state_coyote_time = _current_state_properties.coyote_time
+		else:
+			_state_coyote_time = 0.0
+		_state_time_elapsed = 0.0
+		# Play animation.
+		var anim_name := _current_state_properties.animation_name
+		if _current_state_properties.animation_alt_name && randf() < 0.5:
+			anim_name = _current_state_properties.animation_alt_name
+		_anim_player.play(
+			anim_name,
+			_current_state_properties.animation_blend_time
+		)
+		# Play audio.
+		if _current_state_properties.audio:
+			play_sound(
+				_current_state_properties.audio,
+				_current_state_properties.audio_volume_db
+			)
+		# Freeze/unfreeze.
+		freeze = (
+			_current_state_properties.physics_mode ==
+			CharacterStateProperties.PhysicsMode.FREEZE
+		)
+		# If entering non-carrying state, drop carried object.
+		if !_current_state_properties.is_carrying_state:
+			_put_down_without_animation()
+		# Save collider and etc properties to the stack
+		# and overwrite them with those provided by the state profile.
+		_property_save_restore_stack.push({
+			^".:_collider:rotation": (
+				Vector3.RIGHT*PI/2.0
+				if _current_state_properties.collider_horizontal
+				else Vector3.ZERO
+			),
+			^".:height": (
+				2.0*_current_state_properties.collider_radius
+				if _current_state_properties.collider_horizontal
+				else _current_state_properties.collider_length
+			),
+			^".:width": (
+				_current_state_properties.collider_length
+				if _current_state_properties.collider_horizontal
+				else 2.0*_current_state_properties.collider_radius
+			)
+		})
+		_property_save_restore_stack.push(_current_state_properties.etc)
+
+func _handle_collision_with_other_character(other: Character) -> void:
+	if (
+		other.is_attacking() &&
+		!self.is_invulnerable()
+	):
+		take_damage(
+			other.get_attack_damage(),
+			other.get_attack_knockback(),
+			other.global_position,
+			other.linear_velocity
+		)
+
+func _check_contacts() -> void:
+	# Sort static contacts into ground or wall and sum them up.
+	var new_ground := Vector3.ZERO
+	var new_wall := Vector3.ZERO
+	for i in _body_state.get_contact_count():
+		var other := _body_state.get_contact_collider_object(i)
+		if other is StaticBody3D:
+			var contact_normal := _body_state.get_contact_local_normal(i)
+			if acos(contact_normal.dot(ground_normal)) <= _max_slope_angle:
+				new_ground += contact_normal
+			else:
+				new_wall += contact_normal
+		# Along the way, handle collisions with other kinds of objects.
+		elif other is Character:
+			_handle_collision_with_other_character(other)
+	# Update stored normals only if any direction "wins."
+	_grounded = !new_ground.is_zero_approx()
+	_touching_wall = !new_wall.is_zero_approx()
+	if _grounded:
+		ground_normal = new_ground.normalized()
+	if _touching_wall:
+		wall_normal = new_wall.normalized()
+
+func _reorient() -> void:
+	var gi := global_impetus
+	# Unless grounded, turn right-side-up.
+	if !_current_state_properties.counts_as_grounded:
+		var target_ground_normal: Vector3 = (
+			Vector3.UP if gravity_scale >= 0.0 else Vector3.DOWN
+		)
+		ground_normal = lerp(
+			ground_normal, target_ground_normal,
+			1.0 - (1.0 - _air_righting_lerp_speed)**_body_state.step
+		) as Vector3
+		if ground_normal.is_zero_approx():
+			ground_normal = target_ground_normal
+		ground_normal = ground_normal.normalized()
+	# Determine target yaw.
+	var target_forward := Vector3.ZERO
+	match _current_state_properties.yaw_orientation:
+		CharacterStateProperties.OrientationMode.MANUAL:
+			# If manual, do not modify yaw.
+			target_forward = -basis.z
+		CharacterStateProperties.OrientationMode.FOLLOW_VELOCITY:
+			# If follow velocity, face velocity direction.
+			if !linear_velocity.is_zero_approx():
+				target_forward = linear_velocity.normalized()
+		CharacterStateProperties.OrientationMode.FOLLOW_ACCELERATION:
+			# If follow acceleration, face impetus direction.
+			if !gi.is_zero_approx():
+				target_forward = gi.normalized()
+		CharacterStateProperties.OrientationMode.FOLLOW_NORMAL:
+			# If follow normal, face away from the wall if there is one.
+			# Else, mimic manual.
+			target_forward = wall_normal if _touching_wall else -basis.z
+	# Determine target pitch.
+	var target_upward := Vector3.ZERO
+	var old_target_forward := target_forward
+	match _current_state_properties.pitch_orientation:
+		CharacterStateProperties.OrientationMode.MANUAL:
+			# If manual, do not modify pitch.
+			target_upward = basis.y
+		CharacterStateProperties.OrientationMode.FOLLOW_VELOCITY:
+			# If follow velocity, add influence from velocity to pitch.
+			target_upward = ground_normal
+			if !linear_velocity.is_zero_approx():
+				var vnorm := linear_velocity.normalized()
+				target_forward += vnorm.project(target_upward)
+		CharacterStateProperties.OrientationMode.FOLLOW_ACCELERATION:
+			# If follow acceleration, add influence from impetus to pitch.
+			target_upward = ground_normal
+			if !gi.is_zero_approx():
+				var inorm := gi.normalized()
+				target_forward += inorm.project(target_upward)
+		CharacterStateProperties.OrientationMode.FOLLOW_NORMAL:
+			# If follow normal, keep pitch level to ground.
+			target_upward = ground_normal
+	# In all cases, remove any pitch that was already implied by target_forward
+	# before other modifications involved in computing pitch.
+	# If pitch follows velocity or acceleration,
+	# then this amounts to *replacing* the pitch with the one implied
+	# by whichever vector the pitch is supposed to follow;
+	# otherwise, it amounts to keeping the pitch level to target_upward plane.
+	target_forward -= old_target_forward.project(target_upward)
+	# If target yaw and pitch are defined and not gimbal-locked,
+	# set angular velocity to try to rotate toward the described basis.
+	if (
+		!target_forward.is_zero_approx() &&
+		!target_forward.cross(target_upward).is_zero_approx()
+	):
+		target_forward = target_forward.normalized()
+		var target_basis := Basis.looking_at(target_forward, target_upward)
+		angular_velocity = (
+			global_basis.x.cross(target_basis.x) +
+			global_basis.y.cross(target_basis.y) +
+			global_basis.z.cross(target_basis.z)
+		)*_current_state_properties.orientation_speed*_body_state.step
+	else:
+		# If target yaw or pitch is undefined,
+		# or they exist but are gimbal-locked,
+		# cease all rotation.
+		angular_velocity = Vector3.ZERO
+
+func _get_effective_impetus() -> Vector3:
+	var result := Vector3.ZERO
+	match _current_state_properties.physics_mode:
+		CharacterStateProperties.PhysicsMode.NORMAL:
+			# In normal mode, impetus is bound to ground plane.
+			result = global_impetus
+			result -= result.project(ground_normal)
+		CharacterStateProperties.PhysicsMode.FREE:
+			# In free mode, impetus may leave ground plane.
+			result = global_impetus
+	if result.is_zero_approx():
+		return Vector3.ZERO
+	else:
+		return result.normalized()
+
+func _do_standard_motion(delta: float) -> void:
+	# In freeze mode, this function does nothing.
+	if (
+		_current_state_properties.physics_mode !=
+		CharacterStateProperties.PhysicsMode.FREEZE
+	):
+		# Check where we are trying to go.
+		var effective_impetus := _get_effective_impetus()
+		# Check whether in free mode.
+		var free_mode := (
+			_current_state_properties.physics_mode ==
+			CharacterStateProperties.PhysicsMode.FREE
+		)
+		# Use air physics iff either in free mode or not grounded (by state).
+		var use_air_physics := free_mode || !is_grounded()
+		# Calculate "volitional" velocity
+		# (component of velocity projected into the space
+		# that our impetus is allowed to affect).
+		var volitional_velocity := linear_velocity
+		if !free_mode:
+			volitional_velocity -= (
+				volitional_velocity.project(ground_normal)
+			)
+		# Determine which top speed to use.
+		var top_speed: float = (
+			_top_air_speed if use_air_physics else _top_ground_speed
+		)
+		# Check if we are exceeding top speed.
+		var too_fast := volitional_velocity.length() > top_speed
+		# Regardless, also check how close we are to top speed, 1.0 max.
+		var top_speed_proximity: float = clamp(
+			volitional_velocity.length()/top_speed, 0.0, 1.0
+		)
+		# Use no friction with air physics.
+		# Otherwise, calculate friction according to speed
+		# (more speed = less friction [yes I know that's not realistic]).
+		physics_material_override.friction = (
+			0.0 if use_air_physics
+			else lerp(_max_friction, 0.0, top_speed_proximity)
+		)
+		# Check which manual deceleration value to use.
+		var manual_deceleration: float = (
+			_air_manual_deceleration if use_air_physics
+			else _ground_manual_deceleration
+		)
+		# Check which automatic deceleration value to use.
+		var auto_deceleration: float = (
+			_air_auto_deceleration if use_air_physics
+			else _ground_auto_deceleration
+		)
+		# If we are using ground physics and not jumping,
+		# apply centrifugal force to allow running upside down.
+		if !use_air_physics && !jump_impetus:
+			apply_central_impulse(
+				-ground_normal*mass*delta*lerp(
+					0.0, _max_centrifugal_acceleration,
+					top_speed_proximity
+				)
+			)
+		# If we are going too fast, adjust impetus
+		# to forbid manual acceleration in the direction we're already going.
+		if too_fast:
+			effective_impetus -= effective_impetus.project(volitional_velocity)
+			if !effective_impetus.is_zero_approx():
+				effective_impetus = effective_impetus.normalized()
+		# If impetus, once adjusted, is zero, and volitional velocity is not,
+		# and we are not on a steep slope, then engage automatic deceleration.
+		if effective_impetus.is_zero_approx():
+			if (
+				!volitional_velocity.is_zero_approx() &&
+				acos(ground_normal.dot(
+					Vector3.UP if gravity_scale >= 0.0 else Vector3.DOWN
+				)) <= _max_slope_angle
+			):
+				apply_central_impulse(
+					-volitional_velocity.normalized() *
+					mass*auto_deceleration*delta
+				)
+		else:
+			# If impetus once adjusted is *not* zero, then proceed as follows:
+			# First, calculate "how decelerative" our impetus is,
+			# on a scale of 0.0 to 1.0.
+			var degree_decelerating: float
+			if volitional_velocity.is_zero_approx():
+				degree_decelerating = 0.0
+			else:
+				degree_decelerating = clamp(
+					(1.0 - (
+						volitional_velocity.normalized().dot(effective_impetus) 
+					))/2.0,
+					0.0, 1.0
+				)
+			# Then, check which forward acceleration value to use.
+			var max_accel: float = (
+				_air_acceleration if use_air_physics else _ground_acceleration
+			)
+			# Then, calculate true forward acceleration as follows:
+			# from standing at rest,
+			# it would be the forward acceleration value we just checked,
+			# but the closer we are to top speed,
+			# the closer that value is dragged down toward 0.0.
+			var forward_accel: float = lerp(
+				max_accel, 0.0, top_speed_proximity
+			)
+			# Then, calculate actual acceleration as follows:
+			# simply interpolate between true forward acceleration
+			# and manual deceleration
+			# depending on "how decelerative" our impetus is.
+			var accel: float = lerp(
+				forward_accel,
+				manual_deceleration,
+				degree_decelerating
+			)
+			# Finally, apply the resultant propulsion.
+			apply_central_impulse(effective_impetus*mass*accel*delta)
+
+func _shapecast(from: Vector3, to: Vector3) -> Dictionary:
+	# prepare from/to sweep
+	var space_state := get_world_3d().direct_space_state
+	var query := PhysicsShapeQueryParameters3D.new()
+	query.collide_with_areas = false
+	query.collide_with_bodies = true
+	query.collision_mask = 0xffffffff
+	query.exclude = [self]
+	query.margin = 0.0
+	query.motion = to - from
+	query.shape = _collider.shape
+	query.transform = Transform3D(_collider.global_transform)
+	query.transform.origin = from
+	# do sweep
+	var dists := space_state.cast_motion(query)
+	# if no collisions: fail
+	if is_equal_approx(dists[0], 1.0) && is_equal_approx(dists[1], 1.0):
+		return {}
+	else:
+		# if collisions: check if any intersections at safe point
+		# (i.e. if safe is actually unsafe)
+		query.motion = Vector3.ZERO
+		query.transform.origin = lerp(from, to, dists[0])
+		if space_state.get_rest_info(query).is_empty():
+			# if no intersections: check if any intersections at unsafe point
+			query.transform.origin = lerp(from, to, dists[1])
+			var result := space_state.get_rest_info(query)
+			if result.is_empty():
+				# if no intersections at unsafe point (should not happen): fail
+				return {}
+			else:
+				# if there is an intersection at unsafe point:
+				# add info about where the safe position is and return
+				result.own_position = (
+					lerp(from, to, dists[0]) - global_basis.y*height/2.0
+				)
+				return result
+		else:
+			# if intersections: safe is actually unsafe, fail
+			return {}
+
+func _get_ledge() -> Vector3:
+	var shapecast := _shapecast(
+		global_position + 2.0*height*ground_normal,
+		global_position - height*wall_normal/2.0
+	)
+	if (
+		!shapecast.is_empty() &&
+		acos(shapecast.normal.dot(ground_normal)) <= _max_slope_angle
+	):
+		return shapecast.own_position - global_position
+	else:
+		return Vector3.ZERO
+
+func _put_down_without_animation() -> void:
+	if _carrying:
+		_carrying.reparent(get_parent())
+		_carrying.process_mode = PROCESS_MODE_INHERIT
+		_carrying = null
+
+func _pick_up_without_animation(what: Node3D) -> void:
+	if _carrying:
+		_put_down_without_animation()
+	what.process_mode = PROCESS_MODE_DISABLED
+	what.reparent(self)
+	_carrying = what
+
+func _state_animation_done() -> bool:
+	return !_anim_player.is_playing() || (
+		_anim_player.current_animation !=
+		_current_state_properties.animation_name && (
+			!_current_state_properties.animation_alt_name ||
+			_anim_player.current_animation !=
+			_current_state_properties.animation_alt_name
+		)
+	)
+
+func _get_jump_initial_velocity() -> float:
+	# h = vt - gt^2/2
+	# dh/dt = v - gt = 0
+	# v = gt
+	# t = v/g
+	# h = v(v/g) - g(v/g)^2/2
+	# h = v^2/g - v^2/(2g)
+	# h = v^2/(2g)
+	# 2gh = v^2
+	# v = sqrt(2gh)
+	var standard_gravity: float = PhysicsServer3D.area_get_param(
+		get_world_3d().space,
+		PhysicsServer3D.AreaParameter.AREA_PARAM_GRAVITY
+	)
+	return sqrt(2.0*standard_gravity*_max_jump_height)
+
+func _get_jump_deceleration() -> float:
+	# v = sqrt(2gh) (from previous algebra)
+	# H = vt - Gt^2/2
+	# vt - H = Gt^2/2
+	# G = 2(vt - H)/t^2 = 2(sqrt(2gh)t - H)/t^2
+	if _state_time_elapsed > 0.0:
+		var standard_gravity: float = PhysicsServer3D.area_get_param(
+			get_world_3d().space,
+			PhysicsServer3D.AreaParameter.AREA_PARAM_GRAVITY
+		)
+		var chonkbert: float = (
+			2.0*(
+				sqrt(
+					2.0*standard_gravity*_max_jump_height
+				)*_state_time_elapsed -
+				_min_jump_height
+			)/(_state_time_elapsed**2.0) -
+			standard_gravity
+		)
+		if chonkbert > 0.0:
+			return chonkbert
+		else:
+			return 0.0
+	else:
+		return 0.0
+
+func _apply_jump_impulse() -> void:
+	apply_central_impulse(mass*(
+		_get_jump_initial_velocity()*ground_normal -
+		linear_velocity.project(ground_normal)
+	))
+
+func _apply_jump_deceleration(delta: float) -> void:
+	apply_central_impulse(
+		mass*sign(gravity_scale)*Vector3.DOWN *
+		_get_jump_deceleration()*delta
+	)
+
+#endregion
+
+#region Public Methods
+
+## Whether the current state counts as grounded.
+func is_grounded() -> bool:
+	return _current_state_properties.counts_as_grounded
+
+## Whether there is a current contact that counts as ground.
+func is_really_grounded() -> bool:
+	return _grounded
+
+## Whether there is a current contact that counts as a wall.
+func is_touching_wall() -> bool:
+	return _touching_wall
+
+## Whether coyote time prevents transition out of the current state.
+func state_coyote_time_active() -> bool:
+	return _current_state_properties.use_coyote_time && (
+		_state_coyote_time > 0.0
+	)
+
+## Whether there has been a ground contact since one coyote timespan ago.
+## [br][br]
+## Certain states use this function to decide whether to transition to falling,
+## independently of whether coyote time is still active for transitions
+## out of the state more generally.
+func air_coyote_time_active() -> bool:
+	return _current_state_properties.use_coyote_time && (
+		_air_time_elapsed <= _current_state_properties.coyote_time
+	)
+
+## Whether the current state counts as an attack.
+func is_attacking() -> bool:
+	return _current_state_properties.is_attack
+
+## Attack damage before defense in current state if it is an attack.
+func get_attack_damage() -> float:
+	if is_attacking():
+		return attack_power*_current_state_properties.attack_base_damage
+	else:
+		return 0.0
+
+## Attack knockback before resistance in current state if it is an attack.
+func get_attack_knockback() -> float:
+	if is_attacking():
+		return knockback*_current_state_properties.attack_base_knockback
+	else:
+		return 0.0
+
+## Whether the character will ignore attacks received.
+func is_invulnerable() -> bool:
+	return _current_state_properties.invulnerable
+
+## Attempts state transition. May fail under certain conditions.
+## [br][br]
+## Failure conditions:[br]
+## * state does not exist (this is also an error condition);[br]
+## * coyote time is active;[br]
+## * state is uninterruptible
+##   (expected to leave it with force_change_state);[br]
+## * state is a carrying state and we are not carrying anything.
+## [br][br]
+## In addition, if we *are* carrying something,
+## and the target state is *not* a carrying state,
+## but has an equivalent_carrying_state,
+## we will cancel this attempt and immediately attempt
+## to transition to the equivalent_carrying_state state instead.
+## In that case, the success or failure status
+## of that alternative transition attempt is returned.
+func change_state(state_name: StringName) -> bool:
+	var prospective_state_properties := (
+		_state_properties[state_name]
+		if state_name in _state_properties
+		else null
+	)
+	if !state_exists(state_name):
+		# Disallow switching to nonexistent state.
+		# In addition, this is an error.
+		push_error("%s does not have state %s" % [self, state_name])
+		return false
+	elif _state_coyote_time > 0.0:
+		# Disallow switching away from state while coyote time is active.
+		return false
+	elif _current_state_properties.uninterruptible:
+		# Disallow switching away from uninterruptible state.
+		return false
+	elif !prospective_state_properties:
+		# Else, allow switching to state if it has no properties profile.
+		# (Remaining restrictions are based on the properties profile.)
+		_state_machine.state = state_name
+		return true
+	elif prospective_state_properties.is_carrying_state && !_carrying:
+		# Disallow switching to carrying state while not carrying anything.
+		return false
+	elif (
+		_carrying &&
+		!prospective_state_properties.is_carrying_state &&
+		prospective_state_properties.equivalent_carrying_state
+	):
+		# If we are carrying something
+		# and there is an equivalent carrying state,
+		# switch to that one instead.
+		return change_state(
+			prospective_state_properties.equivalent_carrying_state
+		)
+	else:
+		# If all checks pass, allow state transition.
+		_state_machine.state = state_name
+		return true
+
+## Directly invokes state transition without the sanity checks of change_state.
+## [br][br]
+## Suggested use is to ensure availability of actions
+## that can safely be exempted from other states' coyote time restrictions
+## and need to be highly responsive, such as jumping.
+func force_change_state(state_name: StringName) -> void:
+	_state_machine.state = state_name
+
+## Teleports the character during the next physics tick.
+func teleport(where: Vector3) -> void:
+	_teleport_requested = true
+	_teleport_target = where
+
+## Lerps the character's position to the given target. Character must be frozen.
+func set_freeze_lerp(where: Vector3) -> void:
+	if freeze:
+		_freeze_lerp_requested = true
+		_freeze_lerp_target = where
+	else:
+		push_error("can't freeze-lerp %s: not frozen" % self)
+
+## Whether any behavior is implemented for some named state.
+func state_exists(state_name: StringName) -> bool:
+	return (
+		state_name in _state_handler_descriptors ||
+		state_name in _state_properties
+	)
+
+## Plays a sound on the character's dedicated audio player.
+func play_sound(sound: AudioStream, volume_db: float = 0.0) -> void:
+	_audio_player.stop()
+	_audio_player.stream = sound
+	_audio_player.volume_db = volume_db
+	_audio_player.play()
+
+## If vulnerable, processes damage and knockback and changes state.
+func take_damage(
+	damage_in: float,
+	knockback_in: float,
+	source: Vector3 = global_position,
+	source_velocity: Vector3 = linear_velocity
+) -> void:
+	if !is_invulnerable():
+		health -= damage_in/defense_power
+		var displacement := global_position - source
+		var relative_velocity := source_velocity - linear_velocity
+		var base_impulse: Vector3
+		if displacement.is_zero_approx():
+			base_impulse = relative_velocity
+		elif relative_velocity.length() < 1.0:
+			base_impulse = displacement.normalized()
+		else:
+			base_impulse = (
+				relative_velocity +
+				displacement.normalized()*relative_velocity.length()
+			).normalized()*relative_velocity.length()
+		if base_impulse.is_zero_approx():
+			base_impulse = Vector3.UP
+		else:
+			base_impulse = (
+				(base_impulse.normalized() + Vector3.UP).normalized() *
+				base_impulse.length()
+			)
+		apply_central_impulse(
+			mass*knockback_in*base_impulse/knockback_resistance
+		)
+
+func state_uninterruptible() -> bool:
+	return _current_state_properties.uninterruptible
+
+## Puts down carried object.
+func put_down(force: bool = false) -> void:
+	if _carrying:
+		if state_uninterruptible():
+			if force:
+				_put_down_without_animation()
+		else:
+			force_change_state(&'put-down')
+
+## Character picks up object and begins carrying it.
+func pick_up(what: Node3D, force: bool = false) -> void:
+	if _carrying:
+		if state_uninterruptible():
+			if force:
+				_put_down_without_animation()
+			else:
+				return
+		else:
+			force_change_state(&'put-down')
+			await _state_machine.state_changed
+	if state_uninterruptible():
+		if force:
+			_pick_up_without_animation(what)
+	else:
+		_carrying = what
+		force_change_state(&'pick-up')
+
+#endregion
+
+#region Protected Methods
+
+## Creates handler descriptors to subscribe to the state machine.
+## [br][br]
+## Subclasses should override this
+## to extend the resulting dictionary with additional descriptors
+## for whatever novel states the subclass implements.
+func _make_state_handler_descriptors() -> Dictionary[StringName, Variant]:
+	return {
+		&'idle': _on_idle_state_tick,
+		&'walk': _on_walk_state_tick,
+		&'run': _on_run_state_tick,
+		&'sprint': _on_sprint_state_tick,
+		&'jump': {
+			&'started': _on_jump_state_start,
+			&'ticked': _on_jump_state_tick
+		},
+		&'fall': _on_fall_state_tick,
+		&'skid': _on_skid_state_tick,
+		&'wall-slide': _on_wall_slide_state_tick,
+		&'swim': _on_swim_state_tick,
+		&'hang': _on_hang_state_tick,
+		&'pull-up': _on_pull_up_state_tick,
+		&'hit': _on_hit_state_tick,
+		&'victory1': _on_victory1_state_tick,
+		&'pick-up': {
+			&'started': _on_pick_up_state_start,
+			&'ticked': _on_pick_up_state_tick
+		},
+		&'put-down': {
+			&'ticked': _on_put_down_state_tick,
+			&'stopped': _on_put_down_state_stop
+		},
+		&'idle-while-holding': _on_idle_while_holding_state_tick,
+		&'walk-while-holding': _on_walk_while_holding_state_tick,
+		&'run-while-holding': _on_run_while_holding_state_tick,
+		&'jump-while-holding': {
+			&'started': _on_jump_while_holding_state_start,
+			&'ticked': _on_jump_while_holding_state_tick
+		},
+		&'fall-while-holding': _on_fall_while_holding_state_tick,
+		&'swim-while-holding': _on_swim_while_holding_state_tick
+	}
+
+#endregion
+
+#region State Handlers
+
+func _on_idle_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	if !is_really_grounded():
+		state = &'fall'
+	elif jump_impetus:
+		force_change_state(&'jump')
+	elif !impetus.is_zero_approx():
+		state = &'walk'
+
+func _on_walk_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	var top_speed_proximity: float = (
+		linear_velocity - linear_velocity.project(ground_normal)
+	).length()/_top_ground_speed
+	if !is_really_grounded():
+		state = &'fall'
+	elif jump_impetus:
+		force_change_state(&'jump')
+	elif top_speed_proximity >= 0.125:
+		state = &'run'
+	elif impetus.is_zero_approx() && top_speed_proximity < 0.03125:
+		state = &'idle'
+
+func _on_run_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	var top_speed_proximity: float = (
+		linear_velocity - linear_velocity.project(ground_normal)
+	).length()/_top_ground_speed
+	if !is_really_grounded() && !air_coyote_time_active():
+		state = &'fall'
+	elif jump_impetus:
+		force_change_state(&'jump')
+	elif top_speed_proximity >= 0.375:
+		state = &'sprint'
+	elif top_speed_proximity < 0.125:
+		state = &'walk'
+
+func _on_sprint_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	var top_speed_proximity: float = (
+		linear_velocity - linear_velocity.project(ground_normal)
+	).length()/_top_ground_speed
+	if !is_really_grounded() && !air_coyote_time_active():
+		state = &'fall'
+	elif jump_impetus:
+		force_change_state(&'jump')
+	elif top_speed_proximity < 0.375:
+		state = &'run'
+	elif _get_effective_impetus().dot(linear_velocity) <= -0.75:
+		state = &'skid'
+
+func _on_jump_state_start() -> void:
+	_apply_jump_impulse()
+
+func _on_jump_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	if !jump_impetus:
+		_apply_jump_deceleration(delta)
+	if is_really_grounded():
+		play_sound(_landing_sound, _landing_sound_volume_db)
+		state = &'run'
+	elif linear_velocity.dot(ground_normal) <= -0.25:
+		state = &'fall'
+	elif _touching_wall:
+		var ledge_distance := _get_ledge()
+		if ledge_distance.is_zero_approx():
+			state = &'wall-slide'
+		else:
+			state = &'hang'
+			set_freeze_lerp(global_position + ledge_distance)
+
+func _on_fall_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	if is_really_grounded():
+		play_sound(_landing_sound, _landing_sound_volume_db)
+		state = &'run'
+	elif is_touching_wall():
+		var ledge_distance := _get_ledge()
+		if ledge_distance.is_zero_approx():
+			state = &'wall-slide'
+		else:
+			state = &'hang'
+			set_freeze_lerp(global_position + ledge_distance)
+
+func _on_hit_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	if is_really_grounded():
+		state = &'idle'
+
+func _on_hang_state_tick(delta: float) -> void:
+	global_basis = global_basis.slerp(
+		Basis.looking_at(-wall_normal, ground_normal),
+		1.0 - 0.01**delta
+	)
+	if jump_impetus && !_freeze_lerp_requested:
+		force_change_state(&'pull-up')
+	elif action1_impetus || action2_impetus:
+		_freeze_lerp_requested = false
+		force_change_state(&'hit')
+		apply_central_impulse(4.0*mass*global_basis.z)
+
+func _on_pull_up_state_tick(_delta: float) -> void:
+	if _state_animation_done():
+		force_change_state(&'idle')
+
+func _on_skid_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	if !is_really_grounded():
+		state = &'fall'
+	elif _get_effective_impetus().dot(linear_velocity) >= 0.0:
+		state = &'walk'
+
+func _on_wall_slide_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	if jump_impetus && _state_coyote_time <= 0.0:
+		ground_normal = (ground_normal + wall_normal).normalized()
+		force_change_state(&'jump')
+	elif is_really_grounded():
+		state = &'run'
+	elif !is_touching_wall():
+		state = &'fall'
+
+func _on_victory1_state_tick(_delta: float) -> void:
+	if _state_animation_done():
+		force_change_state(&'victory2')
+
+func _on_put_down_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	var target_displacement := (
+		0.25*height*Vector3.UP +
+		0.75*width*Vector3.FORWARD
+	)
+	if !_carrying:
+		force_change_state(&'idle')
+	elif _state_animation_done():
+		_carrying.position = target_displacement
+		force_change_state(&'idle')
+	else:
+		_carrying.position = lerp(
+			_carrying.position,
+			target_displacement,
+			_anim_player.current_animation_position /
+			_anim_player.current_animation_length
+		)
+
+func _on_put_down_state_stop() -> void:
+	if _carrying:
+		_carrying.reparent(get_parent())
+		_carrying.process_mode = PROCESS_MODE_INHERIT
+
+func _on_pick_up_state_start() -> void:
+	if _carrying:
+		_carrying.process_mode = PROCESS_MODE_DISABLED
+		_carrying.reparent(self)
+	else:
+		force_change_state(&'idle')
+
+func _on_pick_up_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	var target_displacement := (
+		0.5*height*Vector3.UP +
+		0.5*width*Vector3.FORWARD
+	)
+	if !_carrying:
+		force_change_state(&'idle')
+	elif _state_animation_done():
+		_carrying.position = target_displacement
+		force_change_state(&'idle-while-holding')
+	else:
+		_carrying.position = lerp(
+			_carrying.position,
+			target_displacement,
+			_anim_player.current_animation_position /
+			_anim_player.current_animation_length
+		)
+
+func _on_idle_while_holding_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	if !is_really_grounded():
+		state = &'fall-while-holding'
+	elif jump_impetus:
+		force_change_state(&'jump-while-holding')
+	elif !impetus.is_zero_approx():
+		state = &'walk-while-holding'
+
+func _on_walk_while_holding_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	var top_speed_proximity: float = (
+		linear_velocity - linear_velocity.project(ground_normal)
+	).length()/_top_ground_speed
+	if !is_really_grounded():
+		state = &'fall-while-holding'
+	elif jump_impetus:
+		force_change_state(&'jump-while-holding')
+	elif top_speed_proximity >= 0.125:
+		state = &'run-while-holding'
+	elif impetus.is_zero_approx() && top_speed_proximity < 0.03125:
+		state = &'idle-while-holding'
+
+func _on_run_while_holding_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	var top_speed_proximity: float = (
+		linear_velocity - linear_velocity.project(ground_normal)
+	).length()/_top_ground_speed
+	if !is_really_grounded() && !air_coyote_time_active():
+		state = &'fall-while-holding'
+	elif jump_impetus:
+		force_change_state(&'jump-while-holding')
+	elif top_speed_proximity < 0.125:
+		state = &'walk-while-holding'
+
+func _on_jump_while_holding_state_start() -> void:
+	_apply_jump_impulse()
+
+func _on_jump_while_holding_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	if !jump_impetus:
+		_apply_jump_deceleration(delta)
+	if is_really_grounded():
+		play_sound(_landing_sound, _landing_sound_volume_db)
+		state = &'run-while-holding'
+	elif linear_velocity.dot(ground_normal) <= -0.25:
+		state = &'fall-while-holding'
+
+func _on_fall_while_holding_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+	if is_really_grounded():
+		play_sound(_landing_sound, _landing_sound_volume_db)
+		state = &'run-while-holding'
+
+func _on_swim_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+
+func _on_swim_while_holding_state_tick(delta: float) -> void:
+	_do_standard_motion(delta)
+
+#endregion
diff --git a/characters/base/character.gd.uid b/characters/base/character.gd.uid
new file mode 100644
index 0000000..1b5add7
--- /dev/null
+++ b/characters/base/character.gd.uid
@@ -0,0 +1 @@
+uid://jshmfmeoj28y
diff --git a/characters/base/character_state_properties.gd b/characters/base/character_state_properties.gd
new file mode 100644
index 0000000..0f17c45
--- /dev/null
+++ b/characters/base/character_state_properties.gd
@@ -0,0 +1,120 @@
+class_name CharacterStateProperties extends Resource
+## Descriptor of how a Character's properties should change in some given state.
+
+## How the character should self-orient in this state.
+enum OrientationMode {
+	## Do not self-orient in this state / other code will handle it.
+	MANUAL,
+	## Self-orient toward the direction we are actually moving in.
+	FOLLOW_VELOCITY,
+	## Self-orient toward the direction we are trying to move in.
+	FOLLOW_ACCELERATION,
+	## Self-orient according to the normal of the contact surface.
+	FOLLOW_NORMAL
+}
+
+## How the character should move in this state.
+enum PhysicsMode {
+	## Character responds to impetus projected onto ground plane.
+	NORMAL,
+	## Character responds to impetus unmodified.
+	FREE,
+	## Character does not respond to impetus.
+	NO_IMPETUS,
+	## Character is not subject to physics at all.
+	FREEZE
+}
+
+@export_group("Transitions")
+## Whether to use a timer to prevent stopping this state too quickly.
+@export var use_coyote_time := false
+## Value to assign to the timer on state start if use_coyote_time is true.
+@export var coyote_time: float = 0.0
+## Whether to disallow interrupting this state.
+## [br][br]
+## Even if true, can be overruled with force_change_state.
+## For example, the logic of the uninterruptible state itself
+## can use this strategy for sanctioned state transitions.
+@export var uninterruptible := false
+## Whether this state is to be used while carrying an object.
+## [br][br]
+## If true, it cannot be entered while not carrying an object.
+## If false, entering it will force any carried object to be dropped,
+## unless equivalent_carrying_state is specified,
+## in which case that state will be entered instead.
+@export var is_carrying_state := false
+## State to enter instead of this state if carrying an object.
+@export var equivalent_carrying_state := &''
+
+@export_group("Animation")
+## Animation to play in this state.
+@export var animation_name: StringName = &'idle'
+## If specified, 50% random chance to use this animation instead.
+@export var animation_alt_name: StringName = &''
+## Animation playback rate when not moving.
+@export var animation_base_speed: float = 1.0
+## Animation blend time in seconds.
+@export var animation_blend_time: float = 0.25
+## When moving, actual playback rate is base plus this times speed.
+@export var animation_speedup_with_velocity: float = 0.0
+
+@export_group("Audio")
+## Audio stream to play when entering this state.
+@export var audio: AudioStream = null
+## Volume to use for the audio stream played when entering this state.
+@export var audio_volume_db: float = 0.0
+
+@export_group("Collision")
+## Collider length in meters in this state.
+@export var collider_length: float = 1.0
+## Collider radius in meters in this state.
+@export var collider_radius: float = 0.5
+## Whether collider length axis should be local z rather than local y.
+@export var collider_horizontal := false
+
+@export_group("Orientation")
+## Self-orientation strategy for deciding yaw.
+@export var yaw_orientation := OrientationMode.MANUAL
+## Self-orientation strategy for deciding pitch.
+@export var pitch_orientation := OrientationMode.MANUAL
+## Multiplier for calculated angular velocity required to self-orient.
+@export var orientation_speed: float = 600.0
+
+@export_group("Physics")
+## Whether this state counts as being supported by solid ground.
+## [br][br]
+## The character node also separately tracks empirically
+## whether it is currently supported by solid ground in actuality,
+## but for some purposes, particularly self-righting,
+## it's better to go with whether the current state thinks we are grounded
+## than whether we actually are.
+@export var counts_as_grounded := false
+## How the character should move in this state.
+@export var physics_mode := PhysicsMode.NORMAL
+
+@export_group("Combat")
+## Whether this state is an attack.
+@export var is_attack := false
+## Base damage dealt by this attack.
+@export var attack_base_damage: float = 0.0
+## Base impact for knockback dealt by this attack.
+@export var attack_base_knockback: float = 0.0
+## Whether the character ignores attacks received while in this state.
+@export var invulnerable := false
+
+@export_group("Etc")
+## Properties to override while the character is in this state.
+## [br][br]
+## Each key should be a property path relative to the character node,
+## and the corresponding value should be the desired value
+## to be assigned to the named property.
+## [br][br]
+## The keys are typed as String instead of NodePath to avoid confusion,
+## because, at the time of writing, Godot offers a streamlined way
+## to point an exported NodePath to a Node, but no streamlined way
+## to point it to a property. The API user would have to manually type in
+## the property path anyway, and even then, only the node portion of the path
+## would show in the inspector. Exporting as String instead,
+## while less correct, eliminates potential confusion
+## caused by NodePath's Node-oriented inspector UI.
+@export var etc: Dictionary[String, Variant]
diff --git a/characters/base/character_state_properties.gd.uid b/characters/base/character_state_properties.gd.uid
new file mode 100644
index 0000000..15bfa45
--- /dev/null
+++ b/characters/base/character_state_properties.gd.uid
@@ -0,0 +1 @@
+uid://vogde76hsl0j
diff --git a/characters/base/template_csp_defeat.tres b/characters/base/template_csp_defeat.tres
new file mode 100644
index 0000000..87f40b3
--- /dev/null
+++ b/characters/base/template_csp_defeat.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://oi0fr0o3ieis"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_qyetd"]
+
+[resource]
+script = ExtResource("1_qyetd")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = true
+is_carrying_state = false
+equivalent_carrying_state = &""
+animation_name = &"defeat"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_height = 1.0
+collider_radius = 0.5
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = true
+physics_mode = 2
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = true
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_fall.tres b/characters/base/template_csp_fall.tres
new file mode 100644
index 0000000..fd71543
--- /dev/null
+++ b/characters/base/template_csp_fall.tres
@@ -0,0 +1,31 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://bhq36aidyidqr"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_bhdor"]
+
+[resource]
+script = ExtResource("1_bhdor")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"fall-while-holding"
+animation_name = &"fall"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_length = 1.0
+collider_radius = 0.5
+collider_horizontal = false
+yaw_orientation = 1
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = false
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_fall_while_holding.tres b/characters/base/template_csp_fall_while_holding.tres
new file mode 100644
index 0000000..40cc910
--- /dev/null
+++ b/characters/base/template_csp_fall_while_holding.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://b5sb0w2ex8hyn"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_gs8hr"]
+
+[resource]
+script = ExtResource("1_gs8hr")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"fall-while-holding"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_height = 1.0
+collider_radius = 0.5
+yaw_orientation = 1
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = false
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_hang.tres b/characters/base/template_csp_hang.tres
new file mode 100644
index 0000000..f994cde
--- /dev/null
+++ b/characters/base/template_csp_hang.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://caskx8dmdd3h5"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_f0l30"]
+
+[resource]
+script = ExtResource("1_f0l30")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = true
+is_carrying_state = false
+equivalent_carrying_state = &""
+animation_name = &"hang"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_height = 1.0
+collider_radius = 0.5
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = false
+physics_mode = 3
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = true
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_hit.tres b/characters/base/template_csp_hit.tres
new file mode 100644
index 0000000..c3182a0
--- /dev/null
+++ b/characters/base/template_csp_hit.tres
@@ -0,0 +1,33 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=3 format=3 uid="uid://bhwoe6hvwy4ak"]
+
+[ext_resource type="AudioStream" uid="uid://gsdbpcl71gku" path="res://audio/knockback.ogg" id="1_7n6rg"]
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_b2h4o"]
+
+[resource]
+script = ExtResource("1_b2h4o")
+use_coyote_time = true
+coyote_time = 1.25
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &""
+animation_name = &"hit"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio = ExtResource("1_7n6rg")
+audio_volume_db = 0.0
+collider_length = 1.0
+collider_radius = 0.5
+collider_horizontal = false
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = false
+physics_mode = 2
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = true
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_idle.tres b/characters/base/template_csp_idle.tres
new file mode 100644
index 0000000..a72466f
--- /dev/null
+++ b/characters/base/template_csp_idle.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://sqqxxbj4duf4"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_ylq5n"]
+
+[resource]
+script = ExtResource("1_ylq5n")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"idle-while-holding"
+animation_name = &"idle"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_height = 1.0
+collider_width = 0.5
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_idle_while_holding.tres b/characters/base/template_csp_idle_while_holding.tres
new file mode 100644
index 0000000..8dfc678
--- /dev/null
+++ b/characters/base/template_csp_idle_while_holding.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://cwxqc1hw103ns"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_esksn"]
+
+[resource]
+script = ExtResource("1_esksn")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"idle-while-holding"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_height = 1.0
+collider_radius = 0.5
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_jump.tres b/characters/base/template_csp_jump.tres
new file mode 100644
index 0000000..6984f96
--- /dev/null
+++ b/characters/base/template_csp_jump.tres
@@ -0,0 +1,32 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=3 format=3 uid="uid://da512s5hs7ly"]
+
+[ext_resource type="AudioStream" uid="uid://b7c586tdidtlp" path="res://audio/jump.ogg" id="1_10il7"]
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_d107m"]
+
+[resource]
+script = ExtResource("1_d107m")
+use_coyote_time = true
+coyote_time = 0.25
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"jump-while-holding"
+animation_name = &"jump1"
+animation_alt_name = &"jump2"
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio = ExtResource("1_10il7")
+audio_volume_db = 0.0
+collider_height = 1.0
+collider_radius = 0.5
+yaw_orientation = 1
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = false
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_jump_while_holding.tres b/characters/base/template_csp_jump_while_holding.tres
new file mode 100644
index 0000000..e6ce3fe
--- /dev/null
+++ b/characters/base/template_csp_jump_while_holding.tres
@@ -0,0 +1,32 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=3 format=3 uid="uid://cgx3p61bbw6sw"]
+
+[ext_resource type="AudioStream" uid="uid://b7c586tdidtlp" path="res://audio/jump.ogg" id="1_eq8v0"]
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="2_2rxeo"]
+
+[resource]
+script = ExtResource("2_2rxeo")
+use_coyote_time = true
+coyote_time = 0.25
+uninterruptible = false
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"jump-while-holding"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio = ExtResource("1_eq8v0")
+audio_volume_db = 0.0
+collider_height = 1.0
+collider_radius = 0.5
+yaw_orientation = 1
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = false
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_pick_up.tres b/characters/base/template_csp_pick_up.tres
new file mode 100644
index 0000000..ec1b762
--- /dev/null
+++ b/characters/base/template_csp_pick_up.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://dlenj6oro0pfn"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_462hs"]
+
+[resource]
+script = ExtResource("1_462hs")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = true
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"pick-up"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_height = 1.0
+collider_radius = 0.5
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = true
+physics_mode = 2
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_pull_up.tres b/characters/base/template_csp_pull_up.tres
new file mode 100644
index 0000000..6581581
--- /dev/null
+++ b/characters/base/template_csp_pull_up.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://blflaoqntbe7w"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_fd2fj"]
+
+[resource]
+script = ExtResource("1_fd2fj")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = true
+is_carrying_state = false
+equivalent_carrying_state = &""
+animation_name = &"pull-up"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_height = 1.0
+collider_radius = 0.5
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = true
+physics_mode = 3
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = true
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_put_down.tres b/characters/base/template_csp_put_down.tres
new file mode 100644
index 0000000..acbbb3c
--- /dev/null
+++ b/characters/base/template_csp_put_down.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://c2x7tc3irusqo"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_setmo"]
+
+[resource]
+script = ExtResource("1_setmo")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = true
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"put-down"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_height = 1.0
+collider_radius = 0.5
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = true
+physics_mode = 2
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_run.tres b/characters/base/template_csp_run.tres
new file mode 100644
index 0000000..a295e89
--- /dev/null
+++ b/characters/base/template_csp_run.tres
@@ -0,0 +1,31 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://bjgfhx1i6g8pu"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_gwl8f"]
+
+[resource]
+script = ExtResource("1_gwl8f")
+use_coyote_time = true
+coyote_time = 0.25
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"run-while-holding"
+animation_name = &"run"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.1
+audio_volume_db = 0.0
+collider_length = 1.0
+collider_radius = 0.5
+collider_horizontal = false
+yaw_orientation = 2
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_run_while_holding.tres b/characters/base/template_csp_run_while_holding.tres
new file mode 100644
index 0000000..162e468
--- /dev/null
+++ b/characters/base/template_csp_run_while_holding.tres
@@ -0,0 +1,31 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://c4u68hfaaoeoe"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_m3xgw"]
+
+[resource]
+script = ExtResource("1_m3xgw")
+use_coyote_time = true
+coyote_time = 0.25
+uninterruptible = false
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"run-while-holding"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.1
+audio_volume_db = 0.0
+collider_length = 1.0
+collider_radius = 0.5
+collider_horizontal = false
+yaw_orientation = 2
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_skid.tres b/characters/base/template_csp_skid.tres
new file mode 100644
index 0000000..28ec39c
--- /dev/null
+++ b/characters/base/template_csp_skid.tres
@@ -0,0 +1,32 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=3 format=3 uid="uid://cmk3r1rli555v"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_6x34n"]
+[ext_resource type="AudioStream" uid="uid://cxv0o73if41v1" path="res://audio/slide.ogg" id="1_brnuv"]
+
+[resource]
+script = ExtResource("1_6x34n")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"run-while-holding"
+animation_name = &"skid"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio = ExtResource("1_brnuv")
+audio_volume_db = 0.0
+collider_height = 1.0
+collider_radius = 0.5
+yaw_orientation = 1
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_sprint.tres b/characters/base/template_csp_sprint.tres
new file mode 100644
index 0000000..52a7e60
--- /dev/null
+++ b/characters/base/template_csp_sprint.tres
@@ -0,0 +1,31 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://bvruotly1ghl8"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_a6tcg"]
+
+[resource]
+script = ExtResource("1_a6tcg")
+use_coyote_time = true
+coyote_time = 0.25
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"run-while-holding"
+animation_name = &"sprint"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.1
+audio_volume_db = 0.0
+collider_length = 1.0
+collider_radius = 0.5
+collider_horizontal = false
+yaw_orientation = 1
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_swim.tres b/characters/base/template_csp_swim.tres
new file mode 100644
index 0000000..52ba06d
--- /dev/null
+++ b/characters/base/template_csp_swim.tres
@@ -0,0 +1,31 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://dc346050qtltb"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_bw5dp"]
+
+[resource]
+script = ExtResource("1_bw5dp")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"swim-while-holding"
+animation_name = &"swim"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.1
+audio_volume_db = 0.0
+collider_length = 1.0
+collider_radius = 0.5
+collider_horizontal = false
+yaw_orientation = 2
+pitch_orientation = 2
+orientation_speed = 600.0
+counts_as_grounded = false
+physics_mode = 1
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_swim_while_holding.tres b/characters/base/template_csp_swim_while_holding.tres
new file mode 100644
index 0000000..790d341
--- /dev/null
+++ b/characters/base/template_csp_swim_while_holding.tres
@@ -0,0 +1,31 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://mgefvwuayfk4"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_3onor"]
+
+[resource]
+script = ExtResource("1_3onor")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"swim-while-holding"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.1
+audio_volume_db = 0.0
+collider_length = 1.0
+collider_radius = 0.5
+collider_horizontal = false
+yaw_orientation = 2
+pitch_orientation = 2
+orientation_speed = 600.0
+counts_as_grounded = false
+physics_mode = 1
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_victory1.tres b/characters/base/template_csp_victory1.tres
new file mode 100644
index 0000000..f65c60c
--- /dev/null
+++ b/characters/base/template_csp_victory1.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://cjqc2nyywenfk"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_7e7w4"]
+
+[resource]
+script = ExtResource("1_7e7w4")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = true
+is_carrying_state = false
+equivalent_carrying_state = &""
+animation_name = &"victory1"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_height = 1.0
+collider_radius = 0.5
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = true
+physics_mode = 3
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = true
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_victory2.tres b/characters/base/template_csp_victory2.tres
new file mode 100644
index 0000000..4329de9
--- /dev/null
+++ b/characters/base/template_csp_victory2.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://bss0mt60m7lep"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_df5ac"]
+
+[resource]
+script = ExtResource("1_df5ac")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = true
+is_carrying_state = false
+equivalent_carrying_state = &""
+animation_name = &"victory2"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_height = 1.0
+collider_radius = 0.5
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = true
+physics_mode = 3
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = true
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_walk.tres b/characters/base/template_csp_walk.tres
new file mode 100644
index 0000000..c75fde5
--- /dev/null
+++ b/characters/base/template_csp_walk.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://5mv3ctktmsbm"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_56rm8"]
+
+[resource]
+script = ExtResource("1_56rm8")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"walk-while-holding"
+animation_name = &"walk"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_height = 1.0
+collider_radius = 0.5
+yaw_orientation = 2
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_walk_while_holding.tres b/characters/base/template_csp_walk_while_holding.tres
new file mode 100644
index 0000000..b217efe
--- /dev/null
+++ b/characters/base/template_csp_walk_while_holding.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=2 format=3 uid="uid://dn6h1cdnxolmk"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_sffyy"]
+
+[resource]
+script = ExtResource("1_sffyy")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"walk-while-holding"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_height = 1.0
+collider_radius = 0.5
+yaw_orientation = 2
+pitch_orientation = 3
+orientation_speed = 1.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/base/template_csp_wall_slide.tres b/characters/base/template_csp_wall_slide.tres
new file mode 100644
index 0000000..24b4b35
--- /dev/null
+++ b/characters/base/template_csp_wall_slide.tres
@@ -0,0 +1,33 @@
+[gd_resource type="Resource" script_class="CharacterStateProperties" load_steps=3 format=3 uid="uid://btu3efdwlpr7k"]
+
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="1_gklf8"]
+[ext_resource type="AudioStream" uid="uid://cxv0o73if41v1" path="res://audio/slide.ogg" id="1_vcia5"]
+
+[resource]
+script = ExtResource("1_gklf8")
+use_coyote_time = true
+coyote_time = 0.25
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &""
+animation_name = &"wall-slide"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio = ExtResource("1_vcia5")
+audio_volume_db = 0.0
+collider_length = 1.0
+collider_radius = 0.5
+collider_horizontal = false
+yaw_orientation = 3
+pitch_orientation = 3
+orientation_speed = 1000.0
+counts_as_grounded = false
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
diff --git a/characters/controllers/player_character_controller.gd b/characters/controllers/player_character_controller.gd
new file mode 100644
index 0000000..381bf8c
--- /dev/null
+++ b/characters/controllers/player_character_controller.gd
@@ -0,0 +1,57 @@
+class_name PlayerCharacterController extends Node
+
+@export var camera: Camera3D = null
+@export var character: Character = null
+@export var camera_sensitivity: float = 2.0
+@export var camera_lerp_speed: float = 4.0
+@export var camera_distance: float = 5.0
+@export var camera_min_distance: float = 2.0
+@export var camera_max_distance: float = 10.0
+@export var camera_pitch: float = -0.5
+@export var camera_min_pitch: float = -1.0
+@export var camera_max_pitch: float = -0.2
+
+@onready var camera_yaw: float = (
+	character.global_rotation.y if character else 0.0
+)
+
+func _physics_process(delta: float) -> void:
+	if camera && character:
+		var look_input := Vector3(
+			Input.get_axis(&'look_left', &'look_right'),
+			Input.get_axis(&'look_down', &'look_up'),
+			Input.get_axis(&'zoom_in', &'zoom_out')
+		)
+		camera_distance = clamp(
+			camera_distance + PI*look_input.z*camera_sensitivity*delta,
+			camera_min_distance, camera_max_distance
+		)
+		camera_yaw = fposmod(
+			camera_yaw - look_input.x*camera_sensitivity*delta,
+			2.0*PI
+		)
+		camera_pitch = clamp(
+			camera_pitch + look_input.y*camera_sensitivity*delta,
+			camera_min_pitch, camera_max_pitch
+		)
+		var bas := Basis.IDENTITY.rotated(
+			Vector3.RIGHT, camera_pitch
+		).rotated(
+			Vector3.UP, camera_yaw
+		)
+		bas = Basis.looking_at(-bas.z, character.ground_normal)
+		var posn := character.global_position + camera_distance*bas.z
+		var lerp_weight: float = 1.0 - 0.5**(camera_lerp_speed*delta)
+		camera.global_basis = camera.global_basis.slerp(bas, lerp_weight)
+		camera.global_position = lerp(camera.global_position, posn, lerp_weight)
+		var move_input := Vector2(
+			Input.get_axis(&'move_left', &'move_right'),
+			Input.get_axis(&'move_forward', &'move_backward')
+		)
+		character.global_impetus = (
+			move_input.x*camera.basis.x +
+			move_input.y*camera.basis.z
+		)
+		character.jump_impetus = Input.is_action_pressed(&'jump')
+		character.action1_impetus = Input.is_action_pressed(&'action1')
+		character.action2_impetus = Input.is_action_pressed(&'action2')
diff --git a/characters/controllers/player_character_controller.gd.uid b/characters/controllers/player_character_controller.gd.uid
new file mode 100644
index 0000000..6a27ddf
--- /dev/null
+++ b/characters/controllers/player_character_controller.gd.uid
@@ -0,0 +1 @@
+uid://d26dyumvg37cp
diff --git a/characters/test_stick.tscn b/characters/test_stick.tscn
new file mode 100644
index 0000000..8e11fe3
--- /dev/null
+++ b/characters/test_stick.tscn
@@ -0,0 +1,734 @@
+[gd_scene load_steps=31 format=3 uid="uid://gis0gxap8i8t"]
+
+[ext_resource type="PackedScene" uid="uid://blpbgwklc21k5" path="res://characters/base/base_character.tscn" id="1_xjtlb"]
+[ext_resource type="Script" uid="uid://vogde76hsl0j" path="res://characters/base/character_state_properties.gd" id="2_skd7h"]
+[ext_resource type="PackedScene" uid="uid://8nwke3wilk60" path="res://models/characters/stick.blend" id="2_vksnu"]
+[ext_resource type="AudioStream" uid="uid://gsdbpcl71gku" path="res://audio/knockback.ogg" id="3_pt5mk"]
+[ext_resource type="AudioStream" uid="uid://b7c586tdidtlp" path="res://audio/jump.ogg" id="4_slt4y"]
+[ext_resource type="AudioStream" uid="uid://cxv0o73if41v1" path="res://audio/slide.ogg" id="5_1n8td"]
+
+[sub_resource type="Resource" id="Resource_2hlgv"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = true
+is_carrying_state = false
+equivalent_carrying_state = &""
+animation_name = &"defeat"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_length = 1.3
+collider_radius = 0.18
+collider_horizontal = true
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 2
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = true
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_f3575"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"fall-while-holding"
+animation_name = &"fall"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_length = 0.6
+collider_radius = 0.3
+collider_horizontal = false
+yaw_orientation = 1
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = false
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_pgpxt"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"fall-while-holding"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_length = 1.1
+collider_radius = 0.3
+collider_horizontal = false
+yaw_orientation = 1
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = false
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_pt5mk"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = true
+is_carrying_state = false
+equivalent_carrying_state = &""
+animation_name = &"hang"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_length = 0.1
+collider_radius = 0.05
+collider_horizontal = false
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = false
+physics_mode = 3
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = true
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_skd7h"]
+script = ExtResource("2_skd7h")
+use_coyote_time = true
+coyote_time = 1.25
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &""
+animation_name = &"hit"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio = ExtResource("3_pt5mk")
+audio_volume_db = 0.0
+collider_length = 0.9
+collider_radius = 0.4
+collider_horizontal = false
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = false
+physics_mode = 2
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = true
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_u35sk"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"idle-while-holding"
+animation_name = &"idle"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_length = 1.3
+collider_radius = 0.18
+collider_horizontal = false
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_slt4y"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"idle-while-holding"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_length = 1.3
+collider_radius = 0.25
+collider_horizontal = false
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_h0wc8"]
+script = ExtResource("2_skd7h")
+use_coyote_time = true
+coyote_time = 0.25
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"jump-while-holding"
+animation_name = &"jump1"
+animation_alt_name = &"jump2"
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio = ExtResource("4_slt4y")
+audio_volume_db = 0.0
+collider_length = 1.3
+collider_radius = 0.25
+collider_horizontal = false
+yaw_orientation = 1
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = false
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_op5jh"]
+script = ExtResource("2_skd7h")
+use_coyote_time = true
+coyote_time = 0.25
+uninterruptible = false
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"jump-while-holding"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio = ExtResource("4_slt4y")
+audio_volume_db = 0.0
+collider_length = 1.1
+collider_radius = 0.35
+collider_horizontal = false
+yaw_orientation = 1
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = false
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_mofui"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = true
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"pick-up"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_length = 1.1
+collider_radius = 0.35
+collider_horizontal = false
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 2
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_chbfi"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = true
+is_carrying_state = false
+equivalent_carrying_state = &""
+animation_name = &"pull-up"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_length = 0.1
+collider_radius = 0.05
+collider_horizontal = false
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 3
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = true
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_a20lq"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = true
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"put-down"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_length = 1.1
+collider_radius = 0.35
+collider_horizontal = false
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 2
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_aipdl"]
+script = ExtResource("2_skd7h")
+use_coyote_time = true
+coyote_time = 0.25
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"run-while-holding"
+animation_name = &"run"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.1
+audio_volume_db = 0.0
+collider_length = 1.1
+collider_radius = 0.35
+collider_horizontal = false
+yaw_orientation = 2
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_1n8td"]
+script = ExtResource("2_skd7h")
+use_coyote_time = true
+coyote_time = 0.25
+uninterruptible = false
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"run-while-holding"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.1
+audio_volume_db = 0.0
+collider_length = 1.1
+collider_radius = 0.35
+collider_horizontal = false
+yaw_orientation = 2
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_ul8hc"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"run-while-holding"
+animation_name = &"skid"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio = ExtResource("5_1n8td")
+audio_volume_db = 0.0
+collider_length = 1.1
+collider_radius = 0.35
+collider_horizontal = false
+yaw_orientation = 1
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_s4iwp"]
+script = ExtResource("2_skd7h")
+use_coyote_time = true
+coyote_time = 0.25
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"run-while-holding"
+animation_name = &"sprint"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.1
+audio_volume_db = 0.0
+collider_length = 1.1
+collider_radius = 0.35
+collider_horizontal = false
+yaw_orientation = 1
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_o7tpm"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"swim-while-holding"
+animation_name = &"swim"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.1
+audio_volume_db = 0.0
+collider_length = 1.3
+collider_radius = 0.18
+collider_horizontal = true
+yaw_orientation = 2
+pitch_orientation = 2
+orientation_speed = 600.0
+counts_as_grounded = false
+physics_mode = 1
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_fqgwu"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"swim-while-holding"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.1
+audio_volume_db = 0.0
+collider_length = 1.3
+collider_radius = 0.18
+collider_horizontal = true
+yaw_orientation = 2
+pitch_orientation = 2
+orientation_speed = 600.0
+counts_as_grounded = false
+physics_mode = 1
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_tp4uk"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = true
+is_carrying_state = false
+equivalent_carrying_state = &""
+animation_name = &"victory1"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_length = 1.1
+collider_radius = 0.3
+collider_horizontal = false
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 3
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = true
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_smtit"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = true
+is_carrying_state = false
+equivalent_carrying_state = &""
+animation_name = &"victory2"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_length = 1.3
+collider_radius = 0.25
+collider_horizontal = false
+yaw_orientation = 0
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 3
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = true
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_5i55i"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &"walk-while-holding"
+animation_name = &"walk"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_length = 1.3
+collider_radius = 0.18
+collider_horizontal = false
+yaw_orientation = 2
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_ruaq6"]
+script = ExtResource("2_skd7h")
+use_coyote_time = false
+coyote_time = 0.0
+uninterruptible = false
+is_carrying_state = true
+equivalent_carrying_state = &""
+animation_name = &"walk-while-holding"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio_volume_db = 0.0
+collider_length = 1.3
+collider_radius = 0.25
+collider_horizontal = false
+yaw_orientation = 2
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = true
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="Resource" id="Resource_1oy2a"]
+script = ExtResource("2_skd7h")
+use_coyote_time = true
+coyote_time = 0.25
+uninterruptible = false
+is_carrying_state = false
+equivalent_carrying_state = &""
+animation_name = &"wall-slide"
+animation_alt_name = &""
+animation_base_speed = 1.0
+animation_blend_time = 0.25
+animation_speedup_with_velocity = 0.0
+audio = ExtResource("5_1n8td")
+audio_volume_db = 0.0
+collider_length = 1.3
+collider_radius = 0.05
+collider_horizontal = false
+yaw_orientation = 3
+pitch_orientation = 3
+orientation_speed = 600.0
+counts_as_grounded = false
+physics_mode = 0
+is_attack = false
+attack_base_damage = 0.0
+attack_base_knockback = 0.0
+invulnerable = false
+etc = Dictionary[String, Variant]({})
+metadata/_custom_type_script = "uid://vogde76hsl0j"
+
+[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_v6oe5"]
+radius = 0.18
+height = 1.3
+
+[node name="TestStick" node_paths=PackedStringArray("_anim_player", "_collider") instance=ExtResource("1_xjtlb")]
+mass = 35.0
+_anim_player = NodePath("stick/AnimationPlayer")
+_collider = NodePath("CollisionShape3D")
+_state_properties = Dictionary[StringName, ExtResource("2_skd7h")]({
+&"defeat": SubResource("Resource_2hlgv"),
+&"fall": SubResource("Resource_f3575"),
+&"fall-while-holding": SubResource("Resource_pgpxt"),
+&"hang": SubResource("Resource_pt5mk"),
+&"hit": SubResource("Resource_skd7h"),
+&"idle": SubResource("Resource_u35sk"),
+&"idle-while-holding": SubResource("Resource_slt4y"),
+&"jump": SubResource("Resource_h0wc8"),
+&"jump-while-holding": SubResource("Resource_op5jh"),
+&"pick-up": SubResource("Resource_mofui"),
+&"pull-up": SubResource("Resource_chbfi"),
+&"put-down": SubResource("Resource_a20lq"),
+&"run": SubResource("Resource_aipdl"),
+&"run-while-holding": SubResource("Resource_1n8td"),
+&"skid": SubResource("Resource_ul8hc"),
+&"sprint": SubResource("Resource_s4iwp"),
+&"swim": SubResource("Resource_o7tpm"),
+&"swim-while-holding": SubResource("Resource_fqgwu"),
+&"victory1": SubResource("Resource_tp4uk"),
+&"victory2": SubResource("Resource_smtit"),
+&"walk": SubResource("Resource_5i55i"),
+&"walk-while-holding": SubResource("Resource_ruaq6"),
+&"wall-slide": SubResource("Resource_1oy2a")
+})
+
+[node name="stick" parent="." index="3" instance=ExtResource("2_vksnu")]
+transform = Transform3D(-1, 0, -8.74228e-08, 0, 1, 0, 8.74228e-08, 0, -1, 0, 0, 0)
+
+[node name="Skeleton3D" parent="stick/Stick" index="0"]
+bones/0/position = Vector3(0, 0.646973, 0)
+bones/1/rotation = Quaternion(0.0003625, 1.19209e-07, -4.32134e-11, 1)
+bones/5/position = Vector3(-0.0532939, 0.230331, 0.0647434)
+bones/5/rotation = Quaternion(0.571188, -0.0632699, 0.044224, 0.817181)
+bones/5/scale = Vector3(1, 1, 1)
+bones/6/position = Vector3(0.0532939, 0.230331, 0.0647434)
+bones/6/rotation = Quaternion(0.571188, 0.0632699, -0.044224, 0.817181)
+bones/6/scale = Vector3(1, 1, 1)
+bones/7/scale = Vector3(1, 1, 1)
+bones/8/scale = Vector3(1, 1, 1)
+bones/9/rotation = Quaternion(0.696536, -0.121809, 0.696536, 0.121809)
+bones/10/rotation = Quaternion(0.137446, 0.693669, 0.136943, 0.69367)
+bones/11/rotation = Quaternion(-0.0559155, -0.704892, -0.0559155, 0.704893)
+bones/12/rotation = Quaternion(0.314587, -0.188327, -0.650011, 0.665623)
+bones/13/rotation = Quaternion(0.130504, 1.42832e-08, 1.18041e-08, 0.991448)
+bones/14/position = Vector3(0.000918027, 0.0538679, 0.0506858)
+bones/15/rotation = Quaternion(0.696536, 0.121809, -0.696536, 0.121809)
+bones/16/rotation = Quaternion(0.137446, -0.693669, -0.136943, 0.69367)
+bones/17/rotation = Quaternion(-0.0559155, 0.704892, 0.0559155, 0.704893)
+bones/18/rotation = Quaternion(0.314587, 0.188327, 0.650011, 0.665623)
+bones/19/rotation = Quaternion(0.130504, -1.42851e-08, -1.18268e-08, 0.991448)
+bones/20/position = Vector3(1.01205e-08, 0.0390958, -0.0424486)
+bones/20/rotation = Quaternion(1.0863e-07, 0.745269, -0.666764, 5.62662e-08)
+bones/21/rotation = Quaternion(1, 1.58454e-11, -4.37114e-08, 0.0003625)
+bones/22/rotation = Quaternion(0.000725, 4.37114e-08, -3.16907e-11, 1)
+bones/23/rotation = Quaternion(-0.526096, 2.5005e-07, -1.54437e-07, 0.850425)
+bones/24/rotation = Quaternion(1, 1.58454e-11, -4.37114e-08, 0.0003625)
+bones/25/rotation = Quaternion(0.000725, 4.37114e-08, -3.16907e-11, 1)
+bones/26/rotation = Quaternion(-0.526096, 2.5005e-07, -1.54437e-07, 0.850425)
+bones/27/rotation = Quaternion(-3.71988e-08, -0.130247, 0.991482, 6.56977e-08)
+bones/28/rotation = Quaternion(-0.281594, -3.06002e-08, -8.61524e-09, 0.959534)
+bones/29/rotation = Quaternion(-0.458315, 4.53554e-10, -9.05138e-10, 0.88879)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="." index="4"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.65, 0)
+shape = SubResource("CapsuleShape3D_v6oe5")
+
+[editable path="stick"]
diff --git a/project.godot b/project.godot
index eb828ba..97ad116 100644
--- a/project.godot
+++ b/project.godot
@@ -14,6 +14,177 @@ config/name="Stick the Quick"
 config/features=PackedStringArray("4.4", "GL Compatibility")
 config/icon="res://icon.svg"
 
+[audio]
+
+buses/default_bus_layout="res://audio/bus_layout.tres"
+
+[input]
+
+ui_accept={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194309,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194310,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":0,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":true,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":true,"script":null)
+]
+}
+ui_select={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":0,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":true,"script":null)
+]
+}
+ui_cancel={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194305,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":true,"script":null)
+]
+}
+ui_focus_next={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":true,"script":null)
+]
+}
+ui_focus_prev={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":9,"pressure":0.0,"pressed":true,"script":null)
+]
+}
+ui_page_up={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194323,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":4,"axis_value":1.0,"script":null)
+]
+}
+ui_page_down={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194324,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":5,"axis_value":1.0,"script":null)
+]
+}
+look_up={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":1.0,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194440,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+]
+}
+look_down={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":-1.0,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194446,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+]
+}
+look_left={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":-1.0,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194442,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+]
+}
+look_right={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":1.0,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194444,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+]
+}
+look_auto={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":9,"pressure":0.0,"pressed":true,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194443,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+]
+}
+zoom_in={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":5,"axis_value":1.0,"script":null)
+, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":4,"canceled":false,"pressed":false,"double_click":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":61,"key_label":0,"unicode":61,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194437,"key_label":0,"unicode":43,"location":0,"echo":false,"script":null)
+]
+}
+zoom_out={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":4,"axis_value":1.0,"script":null)
+, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":5,"canceled":false,"pressed":false,"double_click":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":45,"key_label":0,"unicode":45,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194435,"key_label":0,"unicode":45,"location":0,"echo":false,"script":null)
+]
+}
+target={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":7,"pressure":0.0,"pressed":true,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":8,"pressure":0.0,"pressed":true,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":true,"script":null)
+, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":3,"canceled":false,"pressed":false,"double_click":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194306,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+]
+}
+move_forward={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":11,"pressure":0.0,"pressed":true,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
+]
+}
+move_backward={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":12,"pressure":0.0,"pressed":true,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
+]
+}
+move_left={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":-1.0,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":13,"pressure":0.0,"pressed":true,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
+]
+}
+move_right={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":1.0,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":14,"pressure":0.0,"pressed":true,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
+]
+}
+jump={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":true,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
+]
+}
+interact={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":3,"pressure":0.0,"pressed":true,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
+]
+}
+action1={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":true,"script":null)
+, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
+]
+}
+action2={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":2,"pressure":0.0,"pressed":true,"script":null)
+, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null)
+]
+}
+pause={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":true,"script":null)
+]
+}
+
 [rendering]
 
 renderer/rendering_method="gl_compatibility"
diff --git a/test/blender_test_map.tscn b/test/blender_test_map.tscn
index eddcb21..f107697 100644
--- a/test/blender_test_map.tscn
+++ b/test/blender_test_map.tscn
@@ -1,6 +1,8 @@
-[gd_scene load_steps=14 format=3 uid="uid://6wjqqijnie4p"]
+[gd_scene load_steps=16 format=3 uid="uid://6wjqqijnie4p"]
 
 [ext_resource type="PackedScene" uid="uid://cp3qagrsuarl5" path="res://models/test/blender_test_map.blend" id="1_jy2qr"]
+[ext_resource type="PackedScene" uid="uid://gis0gxap8i8t" path="res://characters/test_stick.tscn" id="2_hmvb4"]
+[ext_resource type="Script" uid="uid://d26dyumvg37cp" path="res://characters/controllers/player_character_controller.gd" id="3_0cecx"]
 
 [sub_resource type="Gradient" id="Gradient_hmvb4"]
 offsets = PackedFloat32Array(0, 0.327869, 0.663934, 1)
@@ -61,6 +63,9 @@ data = PackedVector3Array(10.1543, 0, -43.3539, 50, 0, -50, 14.3313, 0, -39.177,
 
 [node name="Node3D" type="Node3D"]
 
+[node name="Camera3D" type="Camera3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -10.4229, 13.1742, -7.10402)
+
 [node name="WorldEnvironment" type="WorldEnvironment" parent="."]
 environment = SubResource("Environment_5t8ro")
 
@@ -77,4 +82,13 @@ surface_material_override/0 = SubResource("StandardMaterial3D_fdbvi")
 [node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D"]
 shape = SubResource("ConcavePolygonShape3D_jy2qr")
 
+[node name="TestStick" parent="." instance=ExtResource("2_hmvb4")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -9.54375, 17.2871, -13.5584)
+
+[node name="PlayerCharacterController" type="Node" parent="TestStick" node_paths=PackedStringArray("camera", "character")]
+script = ExtResource("3_0cecx")
+camera = NodePath("../../Camera3D")
+character = NodePath("..")
+metadata/_custom_type_script = "uid://d26dyumvg37cp"
+
 [editable path="StaticBody3D/blender_test_map"]
diff --git a/test/script_test_environment.gd b/test/script_test_environment.gd
new file mode 100644
index 0000000..6d209ae
--- /dev/null
+++ b/test/script_test_environment.gd
@@ -0,0 +1,17 @@
+@tool extends EditorScript
+## Test environment for resolving uncertainties about GDScript semantics.
+
+func _test1() -> void:
+	var foo: Dictionary[String, int] = {&'bar': 1, &'baz/qux:quux': 2}
+	var bar := func (baz: Dictionary) -> Dictionary[NodePath, float]:
+		@warning_ignore("confusable_local_declaration")
+		var qux: Dictionary[NodePath, float] = {}
+		for key in baz:
+			qux[key as NodePath] = baz[key]
+		return qux
+	var qux: Dictionary[NodePath, float] = bar.call(foo)
+	for key in qux:
+		print("%s: %s" % [key, qux[key]])
+
+func _run() -> void:
+	_test1()
diff --git a/test/script_test_environment.gd.uid b/test/script_test_environment.gd.uid
new file mode 100644
index 0000000..08274df
--- /dev/null
+++ b/test/script_test_environment.gd.uid
@@ -0,0 +1 @@
+uid://clwrfawdo8134
diff --git a/util/property_save_restore_stack.gd b/util/property_save_restore_stack.gd
new file mode 100644
index 0000000..ab8fcaf
--- /dev/null
+++ b/util/property_save_restore_stack.gd
@@ -0,0 +1,46 @@
+class_name PropertySaveRestoreStack extends Node
+## Allows saving and restoring sets of properties on a target Node.
+
+## Stores a batch of saved properties to be restored all at once on pop.
+class Frame:
+	## The saved properties to be restored when this frame is popped.
+	var saved_properties: Dictionary[NodePath, Variant]
+	## Saves properties to this frame and then overwrites them on the target.
+	func save_and_modify(target: Node, properties: Dictionary) -> void:
+		for key in properties:
+			var property_path := key as NodePath
+			saved_properties[property_path] = target.get_indexed(property_path)
+			target.set_indexed(property_path, properties[property_path])
+	## Restores saved properties. Called when the frame is popped.
+	func restore(target: Node) -> void:
+		for property_path in saved_properties:
+			target.set_indexed(property_path, saved_properties[property_path])
+
+## All property paths are understood as relative to this Node.
+## [br][br]
+## Altering this property at runtime will immediately empty the stack,
+## restoring all saved properties to the previous target in the process.
+@export var target: Node:
+	set(value):
+		if value != target:
+			while !is_empty():
+				pop()
+			target = value
+
+var _stack: Array[Frame]
+
+## Saves properties to a new stack frame and then overwrites them on the target.
+func push(properties: Dictionary) -> void:
+	var frame := Frame.new()
+	_stack.push_back(frame)
+	frame.save_and_modify(target, properties)
+
+## Pops the top stack frame and restores saved properties from it.
+func pop() -> void:
+	var frame: Frame = _stack.pop_back()
+	if frame:
+		frame.restore(target)
+
+## Whether the stack is empty.
+func is_empty() -> bool:
+	return _stack.is_empty()
diff --git a/util/property_save_restore_stack.gd.uid b/util/property_save_restore_stack.gd.uid
new file mode 100644
index 0000000..1729a27
--- /dev/null
+++ b/util/property_save_restore_stack.gd.uid
@@ -0,0 +1 @@
+uid://dl5vblkrydr4q
diff --git a/util/state_machine.gd b/util/state_machine.gd
new file mode 100644
index 0000000..0ae3ea4
--- /dev/null
+++ b/util/state_machine.gd
@@ -0,0 +1,195 @@
+class_name StateMachine extends Node
+## Can be in one of a set of states. Dispatches signals on tick or transition.
+
+## Stores signals for subscribers interested in specific states.
+class State:
+	## Emitted when entering this state.
+	signal started
+	## Emitted when the StateMachine is ticked while in this state.
+	signal ticked(delta: float)
+	## Emitted when exiting this state.
+	signal stopped
+
+## Stores subscriber connection information for all signals of one state.
+class Handler:
+	## Subscribes to State.started.
+	var on_state_started: Callable = func () -> void: pass
+	## Subscribes to State.ticked.
+	var on_state_ticked: Callable = func (_delta: float) -> void: pass
+	## Subscribes to State.stopped.
+	var on_state_stopped: Callable = func () -> void: pass
+	## How to connect to State.started.
+	var state_started_flags: int
+	## How to connect to State.ticked.
+	var state_ticked_flags: int
+	## How to connect to State.stopped.
+	var state_stopped_flags: int
+	func linkup(
+		param: Variant,
+		param2: Variant = null,
+		param3: Variant = null
+	) -> void:
+		if param is Callable:
+			param3 = param2
+			param2 = param
+			param = &'ticked'
+		if param is StringName:
+			if param == &'started':
+				on_state_started = param2
+				if param3 is int:
+					state_started_flags = param3
+			elif param == &'ticked':
+				on_state_ticked = param2
+				if param3 is int:
+					state_ticked_flags = param3
+			elif param == &'stopped':
+				on_state_stopped = param2
+				if param3 is int:
+					state_stopped_flags = param3
+		elif param is Array:
+			callv(&'linkup', param)
+		elif param is Dictionary:
+			for sig in param:
+				var rest = param[sig]
+				if rest is Callable:
+					linkup(sig, rest)
+				elif rest is Array:
+					rest = rest.duplicate()
+					rest.push_front(sig)
+					callv(&'linkup', rest)
+				else:
+					push_error("StateMachine.Handler invalid param %s" % param)
+		elif param is Handler:
+			on_state_started = param.on_state_started
+			on_state_ticked = param.on_state_ticked
+			on_state_stopped = param.on_state_stopped
+			state_started_flags = param.state_started_flags
+			state_ticked_flags = param.state_ticked_flags
+			state_stopped_flags = param.state_stopped_flags
+		else:
+			push_error("StateMachine.Handler invalid param %s" % param)
+	func _init(
+		param: Variant,
+		param2: Variant = null,
+		param3: Variant = null
+	) -> void:
+		linkup(param, param2, param3)
+
+static func _make_handler_dict(
+	descriptors: Dictionary[StringName, Variant]
+) -> Dictionary[StringName, Handler]:
+	var result: Dictionary[StringName, Handler] = {}
+	for state_name in descriptors:
+		result[state_name] = Handler.new(descriptors[state_name])
+	return result
+
+## Emitted when starting any state (in addition to State.started).
+## [br][br]
+## The state argument is the name of the state that is starting.
+signal state_changed(state: StringName)
+
+var _states: Dictionary[StringName, State] = {}
+var _state: StringName = &''
+
+## Current state. Setting will emit all relevant signals.
+var state: StringName:
+	get():
+		return _state
+	set(state):
+		if _state != state:
+			if _state:
+				_ensure_state_exists(_state)
+				_states[_state].stopped.emit()
+			_state = state
+			state_changed.emit(_state)
+			if _state:
+				_ensure_state_exists(_state)
+				_states[_state].started.emit()
+
+func _ensure_state_exists(state_name: StringName) -> void:
+	if !(state_name in _states):
+		_states[state_name] = State.new()
+
+## Creates described handlers and subscribes them to corresponding states.
+## [br][br]
+## A handler descriptor is a key-value pair in the passed Dictionary.
+## The key part is a StringName referring to any state,
+## and the value part is either a Callable, an Array, or a further Dictionary.
+## The value specifies what Callable(s) to connect to the state's signals,
+## what signal(s) to connect them to, and what connection flags to use.
+## [br][br]
+## If the value part of the key-value pair is a Callable, it is connected
+## to the signal State.ticked, and no connection flags are used.
+## [br][br]
+## If the value part of the key-value pair is an Array, it must contain
+## optionally a StringName which defaults to &'ticked' if not provided,
+## mandatorily a Callable, and optionally an int which defaults to 0
+## if not provided, in that order; these, respectively, are the signal name
+## to connect to, the Callable to connect to it, and the connection flags
+## to use.
+## [br][br]
+## If the value part of the key-value pair is an additional Dictionary,
+## then for each key-value pair of that subordinate Dictionary,
+## the key part is a StringName referring to a signal member of State
+## (either &'started', &'ticked', or &'stopped') and specifies which signal
+## to connect the value part to, and the value part is either a Callable
+## or an array containing a Callable and an int indicating connection flags
+## to use.
+## [br][br]
+## Any Callable which is ultimately to be connected to Signal.started
+## or Signal.stopped should take no arguments and return nothing.
+## Any Callable which is ultimately to be connected to Signal.ticked
+## should take one float and return nothing. The float will be the time delta
+## of the physics step.
+## [br][br]
+## If you are interested in performing some uniform action
+## whenever any state starts, no matter which it is,
+## prefer to subscribe to StateMachine.state_changed.
+func connect_handlers(
+	handler_descriptors: Dictionary[StringName, Variant]
+) -> void:
+	var handlers := _make_handler_dict(handler_descriptors)
+	for state_name in handlers:
+		_ensure_state_exists(state_name)
+		var state_obj := _states[state_name]
+		var handler := handlers[state_name]
+		state_obj.started.connect(
+			handler.on_state_started,
+			handler.state_started_flags
+		)
+		state_obj.ticked.connect(
+			handler.on_state_ticked,
+			handler.state_ticked_flags
+		)
+		state_obj.stopped.connect(
+			handler.on_state_stopped,
+			handler.state_stopped_flags
+		)
+
+## Unsubscribes described handlers from corresponding states.
+## [br][br]
+## For information on the expected format of the handler_descriptors parameter,
+## see documentation for connect_handlers.
+## [br][br]
+## It is recommended you build the handler_descriptors parameter only once,
+## and use the same Dictionary instance to disconnect as you did to connect,
+## in order to ensure the contained Callable instances being disconnected
+## are the same instances that were initially connected --
+## thus ensuring they are actually disconnected, and not left dangling
+## due to failure to find them among the current connections.
+func disconnect_handlers(
+	handler_descriptors: Dictionary[StringName, Variant]
+) -> void:
+	var handlers := _make_handler_dict(handler_descriptors)
+	for state_name in handlers:
+		_ensure_state_exists(state_name)
+		var state_obj := _states[state_name]
+		var handler := handlers[state_name]
+		state_obj.started.disconnect(handler.on_state_started)
+		state_obj.ticked.disconnect(handler.on_state_ticked)
+		state_obj.stopped.disconnect(handler.on_state_stopped)
+
+func _physics_process(delta: float) -> void:
+	if _state:
+		_ensure_state_exists(_state)
+		_states[_state].ticked.emit(delta)
diff --git a/util/state_machine.gd.uid b/util/state_machine.gd.uid
new file mode 100644
index 0000000..61419b8
--- /dev/null
+++ b/util/state_machine.gd.uid
@@ -0,0 +1 @@
+uid://bafxukojlafvh