diff --git a/src/player/camera/control.rs b/src/player/camera/control.rs
new file mode 100644
index 00000000..6d81b506
--- /dev/null
+++ b/src/player/camera/control.rs
@@ -0,0 +1,77 @@
+use std::f32::consts::FRAC_PI_2;
+
+use crate::{dialog::conditions::dialog_running, player::Player};
+use bevy::{app::RunFixedMainLoop, prelude::*, time::run_fixed_main_schedule};
+use leafwing_input_manager::prelude::*;
+
+use super::{PlayerCamera, PlayerCameraConfig};
+
+pub(super) fn plugin(app: &mut App) {
+    app.add_plugins(InputManagerPlugin::<CameraAction>::default());
+    app.add_systems(
+        RunFixedMainLoop,
+        rotate_camera
+            .run_if(not(dialog_running))
+            .before(run_fixed_main_schedule),
+    );
+    app.add_systems(Update, follow_player);
+}
+
+#[derive(Actionlike, PartialEq, Eq, Clone, Copy, Hash, Debug, Reflect)]
+#[actionlike(DualAxis)]
+pub enum CameraAction {
+    RotateCamera,
+}
+
+impl CameraAction {
+    pub fn default_input_map() -> InputMap<Self> {
+        let mut input_map = InputMap::default();
+
+        // Default gamepad input bindings
+        input_map.insert_dual_axis(CameraAction::RotateCamera, GamepadStick::LEFT);
+
+        // Default kbm input bindings
+        input_map.insert_dual_axis(CameraAction::RotateCamera, MouseMove::default());
+
+        input_map
+    }
+}
+
+fn follow_player(
+    mut q_camera: Query<&mut Transform, With<PlayerCamera>>,
+    q_player: Query<&Transform, (With<Player>, Without<PlayerCamera>)>,
+) {
+    // Use `Transform` instead of `Position`` because we want the camera to move
+    // smoothly, so we use the interpolated transform of the player.
+    let Ok(player_transform) = q_player.get_single() else {
+        return;
+    };
+    let Ok(mut camera_transform) = q_camera.get_single_mut() else {
+        return;
+    };
+    let height_offset = 0.5;
+    camera_transform.translation =
+        player_transform.translation + player_transform.up() * height_offset;
+}
+
+fn rotate_camera(
+    mut character_query: Query<(
+        &mut Transform,
+        &ActionState<CameraAction>,
+        &PlayerCameraConfig,
+    )>,
+) {
+    for (mut transform, action_state, config) in &mut character_query {
+        let delta = action_state.axis_pair(&CameraAction::RotateCamera);
+        let delta_yaw = -delta.x * config.sensitivity.x;
+        let delta_pitch = -delta.y * config.sensitivity.y;
+
+        let (yaw, pitch, roll) = transform.rotation.to_euler(EulerRot::YXZ);
+        let yaw = yaw + delta_yaw;
+
+        const PITCH_LIMIT: f32 = FRAC_PI_2 - 0.01;
+        let pitch = (pitch + delta_pitch).clamp(-PITCH_LIMIT, PITCH_LIMIT);
+
+        transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch, roll);
+    }
+}
diff --git a/src/player/camera/mod.rs b/src/player/camera/mod.rs
index 7454210b..a786c0da 100644
--- a/src/player/camera/mod.rs
+++ b/src/player/camera/mod.rs
@@ -1,24 +1,14 @@
-use std::f32::consts::FRAC_PI_2;
-
-use crate::{dialog::conditions::dialog_running, ui_camera::UiCamera};
-use bevy::{app::RunFixedMainLoop, prelude::*, time::run_fixed_main_schedule};
+use crate::ui_camera::UiCamera;
+use bevy::prelude::*;
+use control::CameraAction;
 use leafwing_input_manager::prelude::*;
 
-use super::Player;
-
+mod control;
 mod on_dialog;
 
 pub(super) fn plugin(app: &mut App) {
     app.register_type::<(PlayerCamera, PlayerCameraConfig)>();
-    app.add_plugins(InputManagerPlugin::<CameraAction>::default());
-    app.add_systems(
-        RunFixedMainLoop,
-        rotate_camera
-            .run_if(not(dialog_running))
-            .before(run_fixed_main_schedule),
-    );
-    app.add_systems(Update, follow_player);
-    app.add_plugins(on_dialog::plugin);
+    app.add_plugins((on_dialog::plugin, control::plugin));
 }
 
 #[derive(Component, Default, Reflect)]
@@ -48,26 +38,6 @@ impl Default for PlayerCameraConfig {
     }
 }
 
-#[derive(Actionlike, PartialEq, Eq, Clone, Copy, Hash, Debug, Reflect)]
-#[actionlike(DualAxis)]
-pub enum CameraAction {
-    RotateCamera,
-}
-
-impl CameraAction {
-    pub fn default_input_map() -> InputMap<Self> {
-        let mut input_map = InputMap::default();
-
-        // Default gamepad input bindings
-        input_map.insert_dual_axis(CameraAction::RotateCamera, GamepadStick::LEFT);
-
-        // Default kbm input bindings
-        input_map.insert_dual_axis(CameraAction::RotateCamera, MouseMove::default());
-
-        input_map
-    }
-}
-
 pub fn spawn_player_camera(world: &mut World) {
     let ui_cameras: Vec<_> = world
         .query_filtered::<Entity, With<UiCamera>>()
@@ -85,42 +55,3 @@ pub fn spawn_player_camera(world: &mut World) {
         InputManagerBundle::with_map(CameraAction::default_input_map()),
     ));
 }
-
-fn follow_player(
-    mut q_camera: Query<&mut Transform, With<PlayerCamera>>,
-    q_player: Query<&Transform, (With<Player>, Without<PlayerCamera>)>,
-) {
-    // Use `Transform` instead of `Position`` because we want the camera to move
-    // smoothly, so we use the interpolated transform of the player.
-    let Ok(player_transform) = q_player.get_single() else {
-        return;
-    };
-    let Ok(mut camera_transform) = q_camera.get_single_mut() else {
-        return;
-    };
-    let height_offset = 0.5;
-    camera_transform.translation =
-        player_transform.translation + player_transform.up() * height_offset;
-}
-
-fn rotate_camera(
-    mut character_query: Query<(
-        &mut Transform,
-        &ActionState<CameraAction>,
-        &PlayerCameraConfig,
-    )>,
-) {
-    for (mut transform, action_state, config) in &mut character_query {
-        let delta = action_state.axis_pair(&CameraAction::RotateCamera);
-        let delta_yaw = -delta.x * config.sensitivity.x;
-        let delta_pitch = -delta.y * config.sensitivity.y;
-
-        let (yaw, pitch, roll) = transform.rotation.to_euler(EulerRot::YXZ);
-        let yaw = yaw + delta_yaw;
-
-        const PITCH_LIMIT: f32 = FRAC_PI_2 - 0.01;
-        let pitch = (pitch + delta_pitch).clamp(-PITCH_LIMIT, PITCH_LIMIT);
-
-        transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch, roll);
-    }
-}
diff --git a/src/player/initialize.rs b/src/player/initialize.rs
index f9b3296a..68fc1c48 100644
--- a/src/player/initialize.rs
+++ b/src/player/initialize.rs
@@ -9,7 +9,7 @@ use crate::{
     character::{action::CharacterAction, controller::OverrideForwardDirection},
 };
 
-use super::{camera::PlayerCamera, interactions::components::AvailablePlayerInteraction, Player};
+use super::{camera::PlayerCamera, interactions::AvailablePlayerInteraction, Player};
 
 pub(super) fn plugin(app: &mut App) {
     app.load_resource::<PlayerAssets>();
diff --git a/src/player/interactions/components.rs b/src/player/interactions/components.rs
deleted file mode 100644
index ae25aa52..00000000
--- a/src/player/interactions/components.rs
+++ /dev/null
@@ -1,63 +0,0 @@
-use bevy::{
-    ecs::component::{ComponentHooks, StorageType},
-    prelude::*,
-};
-
-pub(super) fn plugin(app: &mut App) {
-    app.register_type::<(
-        PlayerInteractionParameters,
-        AvailablePlayerInteraction,
-        PlayerInteraction,
-    )>();
-}
-
-#[derive(Debug, Component, PartialEq, Eq, Clone, Default, Deref, DerefMut, Reflect)]
-#[reflect(Component, PartialEq, Default)]
-pub struct AvailablePlayerInteraction(pub Option<Entity>);
-
-/// The general idea is as follows:
-/// This component sits on a collider for an interactable object, e.g. a door or a character.
-/// Every update, we send a raycast from the camera's forward direction to see if it hits a
-/// [`PotentialOpportunity`] collider.
-/// If so, we have an interaction opportunity.
-#[derive(Debug, Component, PartialEq, Clone, Reflect)]
-#[reflect(Component, PartialEq)]
-pub struct PlayerInteractionParameters {
-    /// The prompt to display when the opportunity is available.
-    pub prompt: String,
-    /// The maximum distance from the camera at which the opportunity can be interacted with.
-    pub max_distance: f32,
-}
-
-impl PlayerInteractionParameters {
-    pub fn default(player_interaction: &PlayerInteraction) -> Self {
-        match player_interaction {
-            PlayerInteraction::Dialog(..) => Self {
-                prompt: "Talk".to_string(),
-                max_distance: 2.5,
-            },
-        }
-    }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Reflect)]
-#[reflect(PartialEq, Component)]
-pub enum PlayerInteraction {
-    /// A dialog opportunity with a Yarn Spinner dialogue node.
-    Dialog(String),
-}
-
-impl Component for PlayerInteraction {
-    const STORAGE_TYPE: StorageType = StorageType::Table;
-
-    fn register_component_hooks(hooks: &mut ComponentHooks) {
-        hooks.on_add(|mut world, entity, _component_id| {
-            if world.get::<PlayerInteractionParameters>(entity).is_some() {
-                return;
-            }
-            let interaction = world.get::<PlayerInteraction>(entity).unwrap();
-            let parameters = PlayerInteractionParameters::default(interaction);
-            world.commands().entity(entity).insert(parameters);
-        });
-    }
-}
diff --git a/src/player/interactions/interact.rs b/src/player/interactions/interact.rs
index 1a956bc4..28b44aba 100644
--- a/src/player/interactions/interact.rs
+++ b/src/player/interactions/interact.rs
@@ -4,10 +4,7 @@ use leafwing_input_manager::prelude::*;
 use crate::{character::action::CharacterAction, dialog::StartDialog, player::Player};
 
 use super::{
-    components::{
-        AvailablePlayerInteraction, PlayerInteraction,
-    },
-    OpportunitySystem,
+    OpportunitySystem, {AvailablePlayerInteraction, PlayerInteraction},
 };
 
 pub(super) fn plugin(app: &mut App) {
diff --git a/src/player/interactions/mod.rs b/src/player/interactions/mod.rs
index fe1b8975..c99bd07b 100644
--- a/src/player/interactions/mod.rs
+++ b/src/player/interactions/mod.rs
@@ -1,18 +1,20 @@
 use crate::system_set::VariableGameSystem;
-use bevy::prelude::*;
+use bevy::{
+    ecs::component::{ComponentHooks, StorageType},
+    prelude::*,
+};
 
-pub mod components;
 mod interact;
 mod prompt;
 mod update_available;
 
 pub(super) fn plugin(app: &mut App) {
-    app.add_plugins((
-        components::plugin,
-        prompt::plugin,
-        interact::plugin,
-        update_available::plugin,
-    ));
+    app.register_type::<(
+        PlayerInteractionParameters,
+        AvailablePlayerInteraction,
+        PlayerInteraction,
+    )>();
+    app.add_plugins((prompt::plugin, interact::plugin, update_available::plugin));
     app.configure_sets(
         Update,
         (
@@ -37,3 +39,54 @@ enum OpportunitySystem {
     /// Handles the player interacting with the best available opportunity.
     Interact,
 }
+
+#[derive(Debug, Component, PartialEq, Eq, Clone, Default, Deref, DerefMut, Reflect)]
+#[reflect(Component, PartialEq, Default)]
+pub struct AvailablePlayerInteraction(pub Option<Entity>);
+
+/// The general idea is as follows:
+/// This component sits on a collider for an interactable object, e.g. a door or a character.
+/// Every update, we send a raycast from the camera's forward direction to see if it hits a
+/// [`PotentialOpportunity`] collider.
+/// If so, we have an interaction opportunity.
+#[derive(Debug, Component, PartialEq, Clone, Reflect)]
+#[reflect(Component, PartialEq)]
+pub struct PlayerInteractionParameters {
+    /// The prompt to display when the opportunity is available.
+    pub prompt: String,
+    /// The maximum distance from the camera at which the opportunity can be interacted with.
+    pub max_distance: f32,
+}
+
+impl PlayerInteractionParameters {
+    pub fn default(player_interaction: &PlayerInteraction) -> Self {
+        match player_interaction {
+            PlayerInteraction::Dialog(..) => Self {
+                prompt: "Talk".to_string(),
+                max_distance: 2.5,
+            },
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Reflect)]
+#[reflect(PartialEq, Component)]
+pub enum PlayerInteraction {
+    /// A dialog opportunity with a Yarn Spinner dialogue node.
+    Dialog(String),
+}
+
+impl Component for PlayerInteraction {
+    const STORAGE_TYPE: StorageType = StorageType::Table;
+
+    fn register_component_hooks(hooks: &mut ComponentHooks) {
+        hooks.on_add(|mut world, entity, _component_id| {
+            if world.get::<PlayerInteractionParameters>(entity).is_some() {
+                return;
+            }
+            let interaction = world.get::<PlayerInteraction>(entity).unwrap();
+            let parameters = PlayerInteractionParameters::default(interaction);
+            world.commands().entity(entity).insert(parameters);
+        });
+    }
+}
diff --git a/src/player/interactions/prompt.rs b/src/player/interactions/prompt.rs
index 38c64f2a..da60bab5 100644
--- a/src/player/interactions/prompt.rs
+++ b/src/player/interactions/prompt.rs
@@ -3,8 +3,7 @@ use bevy::ui::Val::*;
 use sickle_ui::{prelude::*, ui_commands::SetTextExt as _};
 
 use super::{
-    components::{AvailablePlayerInteraction, PlayerInteractionParameters},
-    OpportunitySystem,
+    OpportunitySystem, {AvailablePlayerInteraction, PlayerInteractionParameters},
 };
 
 pub(super) fn plugin(app: &mut App) {
diff --git a/src/player/interactions/update_available.rs b/src/player/interactions/update_available.rs
index 0747ac54..b16fb14d 100644
--- a/src/player/interactions/update_available.rs
+++ b/src/player/interactions/update_available.rs
@@ -7,10 +7,7 @@ use avian3d::prelude::*;
 use bevy::prelude::*;
 
 use super::{
-    components::{
-        AvailablePlayerInteraction, PlayerInteraction, PlayerInteractionParameters,
-    },
-    OpportunitySystem,
+    OpportunitySystem, {AvailablePlayerInteraction, PlayerInteraction, PlayerInteractionParameters},
 };
 
 pub(super) fn plugin(app: &mut App) {