kitgit

tirbofish/dropbear · commit

9e5caa8b268e084490941a2b07690379280ad6da

feature: OnRails, which allows an entities EntityTransform be constricted by a set path. refactor: made collider wireframe use DebugDraw instead of its own pipeline. feature+refactor: allowed for components to be created within the users kotlin codebase instead of a rust only implementation. wip: improving UI with a stolen WidgetType struct from egui LOL

Unverified

Thribhu K <4tkbytes@pm.me> · 2026-03-27 10:31

view full diff

diff --git a/Cargo.toml b/Cargo.toml
index e863633..e665142 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -90,6 +90,7 @@ rkyv = "0.8"
 bevy_mikktspace = "1.0.0"
 naga = { version = "29", features = ["wgsl-in"] }
 uuid = { version = "1.22", features = ["v4", "serde"] }
+splines = "5.0"
 
 [workspace.dependencies.image]
 version = "0.25"
diff --git a/crates/dropbear-engine/src/debug.rs b/crates/dropbear-engine/src/debug.rs
index fb63808..737d3ad 100644
--- a/crates/dropbear-engine/src/debug.rs
+++ b/crates/dropbear-engine/src/debug.rs
@@ -239,6 +239,24 @@ impl DebugDraw {
         }
     }
 
+    /// Draws a wireframe cylinder centered at `center`, aligned to `axis`.
+    pub fn draw_cylinder(&mut self, center: Vec3, half_height: f32, radius: f32, axis: Vec3, colour: [f32; 4]) {
+        let axis = axis.normalize();
+        let top = center + axis * half_height;
+        let bottom = center - axis * half_height;
+
+        self.draw_circle(top, radius, axis, colour);
+        self.draw_circle(bottom, radius, axis, colour);
+
+        let up = if axis.dot(Vec3::Y).abs() < 0.99 { Vec3::Y } else { Vec3::Z };
+        let tangent = axis.cross(up).normalize();
+        let bitangent = axis.cross(tangent).normalize();
+
+        for dir in [tangent, -tangent, bitangent, -bitangent] {
+            self.draw_line(top + dir * radius, bottom + dir * radius, colour);
+        }
+    }
+
     /// Draws a wireframe cone from `apex` extending in `dir`.
     ///
     /// `angle` is the half-angle of the cone in radians.
diff --git a/crates/eucalyptus-core/Cargo.toml b/crates/eucalyptus-core/Cargo.toml
index d445556..45222f8 100644
--- a/crates/eucalyptus-core/Cargo.toml
+++ b/crates/eucalyptus-core/Cargo.toml
@@ -51,6 +51,7 @@ wgpu = {workspace = true, features = ["serde"]}
 rkyv.workspace = true
 bitflags.workspace = true
 uuid.workspace = true
+splines.workspace = true
 
 [features]
 default = []
diff --git a/crates/eucalyptus-core/src/component.rs b/crates/eucalyptus-core/src/component.rs
index 83c98ed..d2b8095 100644
--- a/crates/eucalyptus-core/src/component.rs
+++ b/crates/eucalyptus-core/src/component.rs
@@ -1,5 +1,6 @@
 use crate::hierarchy::EntityTransformExt;
 use crate::physics::PhysicsState;
+use crate::scripting::types::KotlinComponents;
 use crate::ser::model::EucalyptusModel;
 use crate::states::{SerializedMaterialCustomisation, SerializedMeshRenderer};
 use crate::utils::{ResolveReference};
@@ -13,6 +14,7 @@ use dropbear_engine::texture::{Texture, TextureBuilder, TextureReference};
 use dropbear_engine::utils::ResourceReference;
 use egui::{CollapsingHeader, ComboBox, DragValue, Grid, RichText, UiBuilder};
 use hecs::{Entity, World};
+use parking_lot::Mutex;
 pub use serde::{Deserialize, Serialize};
 use std::any::TypeId;
 use std::collections::HashMap;
@@ -20,13 +22,34 @@ use std::collections::hash_map::DefaultHasher;
 use std::future::Future;
 use std::hash::{Hash, Hasher};
 use std::pin::Pin;
-use std::sync::Arc;
+use std::sync::{Arc, LazyLock};
 use std::time::{SystemTime, UNIX_EPOCH};
 
 pub use typetag::*;
 
 pub const DRAGGED_ASSET_ID: &str = "dragged_asset_reference";
 
+/// Metadata for a single Kotlin-defined component type, queued via JNI during JVM startup.
+#[derive(Clone, Debug)]
+pub struct KotlinComponentDecl {
+    /// Fully qualified class name, e.g. `"com.game.PlayerHealth"`.
+    pub fqcn: String,
+    /// Human-readable short name, e.g. `"PlayerHealth"`.
+    pub type_name: String,
+    /// Optional editor category, e.g. `"Gameplay"`.
+    pub category: Option<String>,
+    /// Optional one-line description shown as a tooltip in the editor.
+    pub description: Option<String>,
+}
+
+/// Pending Kotlin component declarations pushed by the JNI bridge before the
+/// [`ComponentRegistry`] is available.
+///
+/// Call [`ComponentRegistry::drain_kotlin_queue`] once after JVM initialisation
+/// to absorb these into the registry.
+pub static KOTLIN_COMPONENT_QUEUE: LazyLock<Mutex<Vec<KotlinComponentDecl>>> =
+    LazyLock::new(|| Mutex::new(Vec::new()));
+
 pub struct ComponentRegistry {
     /// Maps TypeId to ComponentDescriptor for quick lookups
     descriptors: HashMap<LanguageTypeId, ComponentDescriptor>,
@@ -66,11 +89,8 @@ pub type ComponentInitFuture<'a, T> = std::pin::Pin<
 
 type LoaderFuture<'a> = Pin<
     Box<
-        dyn Future<
-                Output = anyhow::Result<
-                    Box<dyn for<'b> FnOnce(&'b mut hecs::EntityBuilder) + Send + Sync>,
-                >,
-            > + Send
+        dyn Future<Output = anyhow::Result<Box<dyn ComponentApply + Send + Sync>>>
+            + Send
             + Sync
             + 'a,
     >,
@@ -93,6 +113,11 @@ type InspectFn = Box<
 
 // fn inspect(&mut self, world: &hecs::World, entity: hecs::Entity, ui: &mut egui::Ui, graphics: Arc<SharedGraphicsContext>);
 
+/// A type of TypeID that is respective of its language.
+///
+/// # Currently supported languages:
+/// - Rust - this uses [`TypeId`] to display types.
+/// - Kotlin - this uses [`String`] to display a FQCN (fully qualified class name, e.g. "com.dropbear.EntityRef")
 #[derive(Clone, Debug, PartialEq, Eq, Hash)]
 pub enum LanguageTypeId {
     Rust(TypeId),
@@ -121,7 +146,7 @@ impl ComponentRegistry {
     where
         T: Component + InspectableComponent + Send + Sync + 'static,
         T::SerializedForm: 'static + Default,
-        T::RequiredComponentTypes: Send + Sync,
+        T::RequiredComponentTypes: Send + Sync + 'static,
     {
         let type_id = LanguageTypeId::Rust(TypeId::of::<T>());
         let serialized_type_id = LanguageTypeId::Rust(TypeId::of::<T::SerializedForm>());
@@ -158,10 +183,8 @@ impl ComponentRegistry {
 
                 Box::pin(async move {
                     let bundle = T::init(serialized, graphics).await?;
-                    let applier: Box<dyn FnOnce(&mut hecs::EntityBuilder) + Send + Sync> =
-                        Box::new(move |builder: &mut hecs::EntityBuilder| {
-                            builder.add_bundle(bundle);
-                        });
+                    let applier: Box<dyn ComponentApply + Send + Sync> =
+                        Box::new(TwoWayApplier::<T> { bundle, _phantom: std::marker::PhantomData });
                     Ok(applier)
                 })
             }),
@@ -439,6 +462,74 @@ impl ComponentRegistry {
             .find(|&type_id| Self::numeric_id(type_id) == id)
             .cloned()
     }
+
+    /// Drains all pending Kotlin component declarations from [`KOTLIN_COMPONENT_QUEUE`] and
+    /// registers them with the registry via [`Self::register_kotlin_descriptor`].
+    ///
+    /// Call this once after JVM initialisation (i.e. after `ScriptManager::load_script` returns)
+    /// and before the first scene load. Declarations pushed by the JNI bridge during JVM startup
+    /// will then be visible in the editor Add-Component picker, finders, and removers.
+    pub fn drain_kotlin_queue(&mut self) {
+        let decls: Vec<KotlinComponentDecl> =
+            std::mem::take(&mut *KOTLIN_COMPONENT_QUEUE.lock());
+        for decl in decls {
+            self.register_kotlin_descriptor(decl);
+        }
+    }
+
+    /// Registers a single Kotlin-defined component type with the registry.
+    ///
+    /// This records a descriptor (visible in the editor), a per-FQCN remover, and a finder.
+    /// Serialisation round-trips are handled by the aggregated [`KotlinComponents`] hecs
+    /// component, which is registered as a normal Rust component via
+    /// `component_registry.register::<KotlinComponents>()`.
+    pub fn register_kotlin_descriptor(&mut self, decl: KotlinComponentDecl) {
+        let id = LanguageTypeId::Kotlin(decl.fqcn.clone());
+        if self.descriptors.contains_key(&id) {
+            return;
+        }
+
+        self.fqtn_to_type.insert(decl.fqcn.clone(), id.clone());
+        if let Some(ref cat) = decl.category {
+            self.categories
+                .entry(cat.clone())
+                .or_default()
+                .push(id.clone());
+        }
+        self.descriptors.insert(
+            id.clone(),
+            ComponentDescriptor {
+                fqtn: decl.fqcn.clone(),
+                type_name: decl.type_name,
+                category: decl.category,
+                description: decl.description,
+                disabled_flags: DisabilityFlags::Never,
+                internal: false,
+            },
+        );
+
+        let fqcn = decl.fqcn.clone();
+        self.removers.insert(
+            id.clone(),
+            Box::new(move |world, entity| {
+                if let Ok(mut kc) = world.get::<&mut KotlinComponents>(entity) {
+                    kc.detach(&fqcn);
+                }
+            }),
+        );
+
+        let fqcn = decl.fqcn;
+        self.finders.insert(
+            id,
+            Box::new(move |world| {
+                world
+                    .query::<(hecs::Entity, &KotlinComponents)>()
+                    .iter()
+                    .filter_map(|(entity, kc)| if kc.has(&fqcn) { Some(entity) } else { None })
+                    .collect()
+            }),
+        );
+    }
 }
 
 impl Default for ComponentRegistry {
@@ -454,12 +545,59 @@ pub trait SerializedComponent: Downcast + dyn_clone::DynClone + Send + Sync {}
 impl_downcast!(SerializedComponent);
 dyn_clone::clone_trait_object!(SerializedComponent);
 
+/// An applier produced by the registry loader that can either populate a fresh
+/// [`hecs::EntityBuilder`] (during scene spawning) or be inserted into an already-existing
+/// entity (when a component is added at runtime).
+pub trait ComponentApply: Send + Sync {
+    /// Adds this component bundle to a [`hecs::EntityBuilder`] for a fresh entity spawn.
+    fn apply_to_builder(self: Box<Self>, builder: &mut hecs::EntityBuilder);
+
+    /// Inserts this component bundle into an already-existing entity.
+    ///
+    /// The default behaviour overwrites any pre-existing component of the same types.
+    /// Override [`Component::apply_to_existing_entity`] on the concrete component type to
+    /// preserve existing auxiliary components.
+    fn apply_to_existing_entity(
+        self: Box<Self>,
+        world: &mut hecs::World,
+        entity: hecs::Entity,
+    ) -> anyhow::Result<()>;
+}
+
+/// Concrete [`ComponentApply`] produced by the registry loader.
+///
+/// Delegates `apply_to_existing_entity` to [`Component::apply_to_existing_entity`] so that
+/// individual component types can override the insertion behaviour.
+pub(crate) struct TwoWayApplier<T: Component> {
+    pub bundle: T::RequiredComponentTypes,
+    pub _phantom: std::marker::PhantomData<T>,
+}
+
+impl<T> ComponentApply for TwoWayApplier<T>
+where
+    T: Component + Send + Sync + 'static,
+    T::RequiredComponentTypes: Send + Sync + 'static,
+{
+    fn apply_to_builder(self: Box<Self>, builder: &mut hecs::EntityBuilder) {
+        builder.add_bundle(self.bundle);
+    }
+
+    fn apply_to_existing_entity(
+        self: Box<Self>,
+        world: &mut hecs::World,
+        entity: hecs::Entity,
+    ) -> anyhow::Result<()> {
+        T::apply_to_existing_entity(self.bundle, world, entity)
+    }
+}
+
 #[derive(Debug, Clone, Default)]
 pub enum DisabilityFlags {
     /// Skip this component's `update_component` when the entity is Disabled.
     #[default]
     Disabled,
     /// Skip this component's `update_component` when the entity is Hidden or Disabled.
+    // they both do the same thing. that needs to be fixed. oh well...
     Hidden,
     /// Never skip this component. Used for meta-components such as `EntityStatus`.
     Never,
@@ -501,6 +639,11 @@ pub trait Component: Sync + Send {
 
     /// Converts [`Self::SerializedForm`] into a [`Component`] instance that can be added to
     /// `hecs::EntityBuilder` during scene initialisation.
+    ///
+    /// Typically the default is this:
+    /// ```rust no-run
+    /// Box::pin(async move { Ok((ser.clone(),)) })
+    /// ```
     fn init(
         ser: &'_ Self::SerializedForm,
         _graphics: Arc<SharedGraphicsContext>,
@@ -519,6 +662,26 @@ pub trait Component: Sync + Send {
     /// Called when saving the scene to disk. Returns the [`Self::SerializedForm`] of the component that can be
     /// saved to disk.
     fn save(&self, _world: &hecs::World, _entity: hecs::Entity) -> Box<dyn SerializedComponent>;
+
+    /// Inserts a bundle returned by [`Self::init`] into an already-existing entity.
+    ///
+    /// The default implementation inserts the full bundle, **overwriting** any auxiliary
+    /// component types that may already be present on the entity. Override this when some
+    /// auxiliary types (e.g. [`EntityTransform`]) should be preserved.
+    fn apply_to_existing_entity(
+        bundle: Self::RequiredComponentTypes,
+        world: &mut hecs::World,
+        entity: hecs::Entity,
+    ) -> anyhow::Result<()>
+    where
+        Self::RequiredComponentTypes: 'static,
+    {
+        let mut builder = hecs::EntityBuilder::new();
+        builder.add_bundle(bundle);
+        world
+            .insert(entity, builder.build())
+            .map_err(|e| anyhow::anyhow!("{e}"))
+    }
 }
 
 pub trait InspectableComponent: Send + Sync {
diff --git a/crates/eucalyptus-core/src/debug.rs b/crates/eucalyptus-core/src/debug.rs
index 45bb0f9..578e229 100644
--- a/crates/eucalyptus-core/src/debug.rs
+++ b/crates/eucalyptus-core/src/debug.rs
@@ -16,7 +16,7 @@ macro_rules! with_debug {
     kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawLine"),
     c
 )]
-fn debug_draw_line(
+fn draw_line(
     #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
     start: &NVector3,
     end: &NVector3,
@@ -32,7 +32,7 @@ fn debug_draw_line(
     kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawRay"),
     c
 )]
-fn debug_draw_ray(
+fn draw_ray(
     #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
     origin: &NVector3,
     dir: &NVector3,
@@ -48,7 +48,7 @@ fn debug_draw_ray(
     kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawArrow"),
     c
 )]
-fn debug_draw_arrow(
+fn draw_arrow(
     #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
     start: &NVector3,
     end: &NVector3,
@@ -64,7 +64,7 @@ fn debug_draw_arrow(
     kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawPoint"),
     c
 )]
-fn debug_draw_point(
+fn draw_point(
     #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
     pos: &NVector3,
     size: f32,
@@ -79,7 +79,7 @@ fn debug_draw_point(
     kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawCircle"),
     c
 )]
-fn debug_draw_circle(
+fn draw_circle(
     #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
     center: &NVector3,
     radius: f32,
@@ -96,7 +96,7 @@ fn debug_draw_circle(
     kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawSphere"),
     c
 )]
-fn debug_draw_sphere(
+fn draw_sphere(
     #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
     center: &NVector3,
     radius: f32,
@@ -111,7 +111,7 @@ fn debug_draw_sphere(
     kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawGlobe"),
     c
 )]
-fn debug_draw_globe(
+fn draw_globe(
     #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
     center: &NVector3,
     radius: f32,
@@ -128,7 +128,7 @@ fn debug_draw_globe(
     kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawAabb"),
     c
 )]
-fn debug_draw_aabb(
+fn draw_aabb(
     #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
     min: &NVector3,
     max: &NVector3,
@@ -144,7 +144,7 @@ fn debug_draw_aabb(
     kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawObb"),
     c
 )]
-fn debug_draw_obb(
+fn draw_obb(
     #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
     center: &NVector3,
     half_extents: &NVector3,
@@ -162,7 +162,7 @@ fn debug_draw_obb(
     kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawCapsule"),
     c
 )]
-fn debug_draw_capsule(
+fn draw_capsule(
     #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
     a: &NVector3,
     b: &NVector3,
@@ -176,10 +176,28 @@ fn debug_draw_capsule(
 }
 
 #[dropbear_macro::export(
+    kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawCylinder"),
+    c
+)]
+fn draw_cylinder(
+    #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
+    center: &NVector3,
+    half_height: f32,
+    radius: f32,
+    axis: &NVector3,
+    colour: &NColour,
+) -> DropbearNativeResult<()> {
+    let c = Vec3::new(center.x as f32, center.y as f32, center.z as f32);
+    let ax = Vec3::new(axis.x as f32, axis.y as f32, axis.z as f32);
+    with_debug!(graphics, |dd| dd.draw_cylinder(c, half_height, radius, ax, colour.to_f32_array()));
+    Ok(())
+}
+
+#[dropbear_macro::export(
     kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawCone"),
     c
 )]
-fn debug_draw_cone(
+fn draw_cone(
     #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
     apex: &NVector3,
     dir: &NVector3,
diff --git a/crates/eucalyptus-core/src/lib.rs b/crates/eucalyptus-core/src/lib.rs
index b4203ac..0a8ac4c 100644
--- a/crates/eucalyptus-core/src/lib.rs
+++ b/crates/eucalyptus-core/src/lib.rs
@@ -39,6 +39,7 @@ use crate::physics::kcc::KCC;
 use crate::physics::rigidbody::RigidBody;
 use crate::billboard::BillboardComponent;
 use crate::entity_status::EntityStatus;
+use crate::scripting::types::KotlinComponents;
 use crate::states::Script;
 use crate::ui::HUDComponent;
 use dropbear_engine::animation::AnimationComponent;
@@ -48,6 +49,7 @@ use dropbear_engine::lighting::Light;
 pub use egui;
 use properties::CustomProperties;
 pub use rapier3d;
+use crate::transform::OnRails;
 
 /// The appdata directory for storing any information.
 ///
@@ -72,4 +74,6 @@ pub fn register_components(component_registry: &mut ComponentRegistry) {
     component_registry.register::<AnimationComponent>();
     component_registry.register::<BillboardComponent>();
     component_registry.register::<HUDComponent>();
+    component_registry.register::<OnRails>();
+    component_registry.register::<KotlinComponents>();
 }
diff --git a/crates/eucalyptus-core/src/scene.rs b/crates/eucalyptus-core/src/scene.rs
index 6e8c5e9..b2ea18b 100644
--- a/crates/eucalyptus-core/src/scene.rs
+++ b/crates/eucalyptus-core/src/scene.rs
@@ -257,7 +257,7 @@ impl SceneConfig {
             for component in components.iter() {
                 if component.as_any().downcast_ref::<Parent>().is_some() {
                     log::debug!(
-                        "Skipping Parent component for '{}' - will be rebuilt from hierarchy_map",
+                        "Skipping Parent component for '{}', will be rebuilt from hierarchy_map",
                         label_for_logs
                     );
                     continue;
@@ -274,7 +274,7 @@ impl SceneConfig {
                 };
 
                 let applier = loader_future.await?;
-                applier(&mut builder);
+                applier.apply_to_builder(&mut builder);
             }
 
             let entity = world.spawn(builder.build());
diff --git a/crates/eucalyptus-core/src/scripting.rs b/crates/eucalyptus-core/src/scripting.rs
index dc47762..f339264 100644
--- a/crates/eucalyptus-core/src/scripting.rs
+++ b/crates/eucalyptus-core/src/scripting.rs
@@ -7,6 +7,7 @@ pub mod jni;
 pub mod native;
 pub mod result;
 pub mod utils;
+pub mod types;
 
 pub static JVM_ARGS: OnceLock<String> = OnceLock::new();
 pub static AWAIT_JDB: OnceLock<bool> = OnceLock::new();
diff --git a/crates/eucalyptus-core/src/scripting/types.rs b/crates/eucalyptus-core/src/scripting/types.rs
new file mode 100644
index 0000000..905d261
--- /dev/null
+++ b/crates/eucalyptus-core/src/scripting/types.rs
@@ -0,0 +1,141 @@
+use crate::component::{
+    Component, ComponentDescriptor, ComponentInitFuture, DisabilityFlags, InspectableComponent,
+    SerializedComponent,
+};
+use crate::physics::PhysicsState;
+use crate::scripting::result::DropbearNativeResult;
+use dropbear_engine::graphics::SharedGraphicsContext;
+use egui::{CollapsingHeader, Ui};
+use hecs::{Entity, World};
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+
+/// Single [`hecs`] component that represents all Kotlin components on this entity.
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct KotlinComponents {
+    /// A list of `fully qualified class names`.
+    pub fqcns: Vec<String>,
+}
+
+#[typetag::serde]
+impl SerializedComponent for KotlinComponents {}
+
+impl KotlinComponents {
+    pub fn attach(&mut self, fqcn: &str) {
+        if !self.fqcns.iter().any(|f| f == fqcn) {
+            self.fqcns.push(fqcn.to_owned());
+        }
+    }
+
+    pub fn detach(&mut self, fqcn: &str) {
+        self.fqcns.retain(|f| f != fqcn);
+    }
+
+    pub fn has(&self, fqcn: &str) -> bool {
+        self.fqcns.iter().any(|f| f == fqcn)
+    }
+}
+
+impl Component for KotlinComponents {
+    type SerializedForm = Self;
+    type RequiredComponentTypes = (Self,);
+
+    fn descriptor() -> ComponentDescriptor {
+        ComponentDescriptor {
+            fqtn: "eucalyptus_core::scripting::types::KotlinComponents".to_string(),
+            type_name: "KotlinComponents".to_string(),
+            category: None,
+            description: Some(
+                "Tracks all Kotlin-defined components attached to this entity.".to_string(),
+            ),
+            disabled_flags: DisabilityFlags::Never,
+            internal: true,
+        }
+    }
+
+    fn init(
+        ser: &'_ Self::SerializedForm,
+        _graphics: Arc<SharedGraphicsContext>,
+    ) -> ComponentInitFuture<'_, Self> {
+        let cloned = ser.clone();
+        Box::pin(async move { Ok((cloned,)) })
+    }
+
+    fn update_component(
+        &mut self,
+        _world: &World,
+        _physics: &mut PhysicsState,
+        _entity: Entity,
+        _dt: f32,
+        _graphics: Arc<SharedGraphicsContext>,
+    ) {
+    }
+
+    fn save(&self, _world: &World, _entity: Entity) -> Box<dyn SerializedComponent> {
+        Box::new(self.clone())
+    }
+}
+
+impl InspectableComponent for KotlinComponents {
+    fn inspect(
+        &mut self,
+        _world: &World,
+        _entity: Entity,
+        ui: &mut Ui,
+        _graphics: Arc<SharedGraphicsContext>,
+    ) {
+        CollapsingHeader::new("Kotlin Components")
+            .default_open(true)
+            .show(ui, |ui| {
+                for fqcn in &self.fqcns {
+                    ui.label(fqcn);
+                }
+            });
+    }
+}
+
+/// Queries whether an entity has a specific Kotlin component attached.
+///
+/// Called from JVM via `ComponentNative.hasKotlinComponent(worldPtr, entityId, fqcn)`.
+#[dropbear_macro::export(kotlin(
+    class = "com.dropbear.components.ComponentNative",
+    func = "hasKotlinComponent",
+))]
+fn has_kotlin_component(
+    #[dropbear_macro::define(WorldPtr)] world: &World,
+    #[dropbear_macro::entity] entity: Entity,
+    fqcn: String,
+) -> DropbearNativeResult<bool> {
+    if let Ok(kc) = world.get::<&KotlinComponents>(entity) {
+        Ok(kc.has(&fqcn))
+    } else {
+        Ok(false)
+    }
+}
+
+/// Registers a Kotlin component type with the engine's [`crate::component::ComponentRegistry`].
+///
+/// Called from JVM during startup via `ComponentNative.registerKotlinComponent(...)`.
+/// The declaration is queued in [`crate::component::KOTLIN_COMPONENT_QUEUE`] and consumed
+/// by [`crate::component::ComponentRegistry::drain_kotlin_queue`] after JVM init completes.
+#[dropbear_macro::export(kotlin(
+    class = "com.dropbear.components.ComponentNative",
+    func = "registerKotlinComponent",
+))]
+fn register_kotlin_component_jni(
+    #[dropbear_macro::define(WorldPtr)] _world: &World,
+    fqcn: String,
+    type_name: String,
+    category: String,
+    description: String,
+) -> DropbearNativeResult<()> {
+    use crate::component::{KotlinComponentDecl, KOTLIN_COMPONENT_QUEUE};
+    KOTLIN_COMPONENT_QUEUE.lock().push(KotlinComponentDecl {
+        fqcn,
+        type_name,
+        category: if category.is_empty() { None } else { Some(category) },
+        description: if description.is_empty() { None } else { Some(description) },
+    });
+    Ok(())
+}
\ No newline at end of file
diff --git a/crates/eucalyptus-core/src/transform.rs b/crates/eucalyptus-core/src/transform.rs
index e1e935c..fe3d817 100644
--- a/crates/eucalyptus-core/src/transform.rs
+++ b/crates/eucalyptus-core/src/transform.rs
@@ -1,4 +1,5 @@
-use crate::component::{Component, ComponentDescriptor, DisabilityFlags, InspectableComponent, SerializedComponent};
+use std::fmt::{Display, Formatter};
+use crate::component::{Component, ComponentDescriptor, ComponentInitFuture, DisabilityFlags, InspectableComponent, SerializedComponent};
 use crate::hierarchy::EntityTransformExt;
 use crate::ptr::WorldPtr;
 use crate::scripting::jni::utils::{FromJObject, ToJObject};
@@ -9,10 +10,388 @@ use ::jni::JNIEnv;
 use ::jni::objects::{JObject, JValue};
 use dropbear_engine::entity::{EntityTransform, Transform};
 use dropbear_engine::graphics::SharedGraphicsContext;
-use egui::{CollapsingHeader, Ui};
-use glam::{DQuat, DVec3};
+use egui::{CollapsingHeader, ComboBox, Ui};
+use glam::{DQuat, DVec3, Vec3};
 use hecs::{Entity, World};
+use splines::{Interpolation, Key, Spline};
 use std::sync::Arc;
+use crate::physics::PhysicsState;
+
+/// Allows for the entity to have constricted movement, such as a Camera that follows a player
+/// on a set path.
+#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct OnRails {
+    /// Whether the component actively moves the entity along the path each frame.
+    pub enabled: bool,
+    pub path: Vec<DVec3>,
+    /// Progress of the entity on the spline between 0.0 to 1.0.
+    pub progress: f32, // between 0.0 to 1.0
+    pub drive: RailDrive,
+    /// Allows for showing the debug path of the rail.
+    #[serde(skip)]
+    pub debug: bool,
+}
+
+/// Returns the progress `t` (0.0–1.0) of the path point closest to `pos`.
+fn project_to_path(path: &[DVec3], pos: DVec3) -> f32 {
+    let n = path.len();
+    if n < 2 {
+        return 0.0;
+    }
+    let mut best_t = 0.0f32;
+    let mut best_dist_sq = f64::MAX;
+    for i in 0..(n - 1) {
+        let a = path[i];
+        let b = path[i + 1];
+        let t_a = i as f32 / (n - 1) as f32;
+        let t_b = (i + 1) as f32 / (n - 1) as f32;
+        let ab = b - a;
+        let ab_len_sq = ab.length_squared();
+        let (seg_t, proj_pos) = if ab_len_sq < 1e-10 {
+            (t_a, a)
+        } else {
+            let frac = ((pos - a).dot(ab) / ab_len_sq).clamp(0.0, 1.0);
+            (t_a + frac as f32 * (t_b - t_a), a + ab * frac)
+        };
+        let dist_sq = pos.distance_squared(proj_pos);
+        if dist_sq < best_dist_sq {
+            best_dist_sq = dist_sq;
+            best_t = seg_t;
+        }
+    }
+    best_t
+}
+
+/// Linearly samples the path at `t` in [0, 1]. Matches the `Interpolation::Linear` splines used
+/// in `update_component`, so the editor gizmo stays consistent with runtime behaviour.
+fn sample_path_linear(path: &[DVec3], t: f32) -> DVec3 {
+    let n = path.len();
+    if n == 0 {
+        return DVec3::ZERO;
+    }
+    if n == 1 {
+        return path[0];
+    }
+    let t = t.clamp(0.0, 1.0) as f64;
+    let seg_f = t * (n - 1) as f64;
+    let i = (seg_f as usize).min(n - 2);
+    let frac = seg_f - i as f64;
+    path[i].lerp(path[i + 1], frac)
+}
+
+/// How the progression of the [`OnRails`] component is handled.
+#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub enum RailDrive {
+    /// Progress advances automatically at a fixed speed.
+    ///
+    /// Use for cutscenes, intros, or automated camera pans.
+    Automatic {
+        speed: f32,
+        looping: bool,
+    },
+
+    /// Progress is tied to the closest point on the rail to a target entity.
+    ///
+    /// Use for third-person or side-scroller cameras following a player.
+    FollowEntity {
+        target: Entity,
+        /// If true, progress can never decrease and the camera won't scroll backwards.
+        monotonic: bool
+    },
+
+    /// Progress is driven by a specific axis of the target entity's world position.
+    ///
+    /// e.g. AxisDriven { target: player, axis: Vec3::X } scrolls as the player moves right.
+    ///
+    /// Use for side-scrollers or top-down cameras with a dominant movement axis.
+    AxisDriven {
+        target: Entity,
+        axis: Vec3,
+        /// Maps world units along the axis to 0.0..1.0 progress.
+        range: (f32, f32),
+    },
+
+    /// Progress is set entirely by external code, such as scripting
+    ///
+    /// The system won't touch progress at all.
+    #[default]
+    Manual,
+}
+
+impl Display for RailDrive {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", match self {
+            RailDrive::Automatic { .. } => "Automatic",
+            RailDrive::FollowEntity { .. } => "FollowEntity",
+            RailDrive::AxisDriven { .. } => "AxisDriven",
+            RailDrive::Manual => "Manual",
+        })
+    }
+}
+
+#[typetag::serde]
+impl SerializedComponent for OnRails {}
+
+impl Component for OnRails {
+    type SerializedForm = Self;
+    type RequiredComponentTypes = (Self, EntityTransform);
+
+    fn descriptor() -> ComponentDescriptor {
+        ComponentDescriptor {
+            fqtn: "eucalyptus_core::transform::OnRails".to_string(),
+            type_name: "OnRails".to_string(),
+            category: Some("Transform".to_string()),
+            description: Some("Moves the entity along a fixed path".to_string()),
+            disabled_flags: DisabilityFlags::Disabled,
+            internal: false,
+        }
+    }
+
+    fn init(ser: &'_ Self::SerializedForm, _graphics: Arc<SharedGraphicsContext>) -> ComponentInitFuture<'_, Self> {
+        Box::pin(async move { Ok((ser.clone(), EntityTransform::default())) })
+    }
+
+    fn update_component(&mut self, world: &World, _physics: &mut PhysicsState, entity: Entity, dt: f32, _graphics: Arc<SharedGraphicsContext>) {
+        if !self.enabled {
+            return;
+        }
+        let n = self.path.len();
+        if n < 2 {
+            return;
+        }
+
+        let make_spline = |get: fn(&DVec3) -> f64| -> Spline<f64, f64> {
+            Spline::from_vec(
+                self.path
+                    .iter()
+                    .enumerate()
+                    .map(|(i, p)| Key::new(i as f64 / (n - 1) as f64, get(p), Interpolation::Linear))
+                    .collect(),
+            )
+        };
+        let sx = make_spline(|p| p.x);
+        let sy = make_spline(|p| p.y);
+        let sz = make_spline(|p| p.z);
+
+        match &self.drive {
+            RailDrive::Automatic { speed, looping } => {
+                let (speed, looping) = (*speed, *looping);
+                self.progress += speed * dt;
+                if looping {
+                    self.progress = self.progress.rem_euclid(1.0);
+                } else {
+                    self.progress = self.progress.clamp(0.0, 1.0);
+                }
+            }
+            RailDrive::FollowEntity { target, monotonic } => {
+                let (target, monotonic) = (*target, *monotonic);
+                let Ok(target_et) = world.get::<&EntityTransform>(target) else {
+                    return;
+                };
+                let target_pos = target_et.world().position;
+                drop(target_et);
+
+                let mut best_t = self.progress;
+                let mut best_dist_sq = f64::MAX;
+                for i in 0..(n - 1) {
+                    let a = self.path[i];
+                    let b = self.path[i + 1];
+                    let t_a = i as f32 / (n - 1) as f32;
+                    let t_b = (i + 1) as f32 / (n - 1) as f32;
+                    let ab = b - a;
+                    let ab_len_sq = ab.length_squared();
+                    let (seg_t, proj_pos) = if ab_len_sq < 1e-10 {
+                        (t_a, a)
+                    } else {
+                        let frac = ((target_pos - a).dot(ab) / ab_len_sq).clamp(0.0, 1.0);
+                        (t_a + frac as f32 * (t_b - t_a), a + ab * frac)
+                    };
+                    let dist_sq = target_pos.distance_squared(proj_pos);
+                    if dist_sq < best_dist_sq {
+                        best_dist_sq = dist_sq;
+                        best_t = seg_t;
+                    }
+                }
+                self.progress = if monotonic {
+                    self.progress.max(best_t)
+                } else {
+                    best_t
+                };
+            }
+            RailDrive::AxisDriven { target, axis, range } => {
+                let (target, axis, range) = (*target, *axis, *range);
+                let Ok(target_et) = world.get::<&EntityTransform>(target) else {
+                    return;
+                };
+                let target_pos = target_et.world().position.as_vec3();
+                drop(target_et);
+
+                let proj = target_pos.dot(axis);
+                self.progress = ((proj - range.0) / (range.1 - range.0)).clamp(0.0, 1.0);
+            }
+            RailDrive::Manual => {}
+        }
+
+        let t = self.progress as f64;
+        if let (Some(x), Some(y), Some(z)) = (
+            sx.clamped_sample(t),
+            sy.clamped_sample(t),
+            sz.clamped_sample(t),
+        ) {
+            if let Ok(mut et) = world.get::<&mut EntityTransform>(entity) {
+                et.world_mut().position = DVec3::new(x, y, z);
+            }
+        }
+    }
+
+    fn save(&self, _world: &World, _entity: Entity) -> Box<dyn SerializedComponent> {
+        Box::new(self.clone())
+    }
+}
+
+impl InspectableComponent for OnRails {
+    fn inspect(
+        &mut self,
+        world: &World,
+        entity: Entity,
+        ui: &mut Ui,
+        _graphics: Arc<SharedGraphicsContext>,
+    ) {
+        let n = self.path.len();
+
+        // if the entity is dragged off the path, it will 
+        if self.enabled && n >= 2 {
+            let expected = sample_path_linear(&self.path, self.progress);
+            let current_pos = world
+                .get::<&EntityTransform>(entity)
+                .ok()
+                .map(|et| et.world().position);
+            if let Some(pos) = current_pos {
+                if pos.distance_squared(expected) > 1e-8 {
+                    self.progress = project_to_path(&self.path, pos);
+                    let snapped = sample_path_linear(&self.path, self.progress);
+                    if let Ok(mut et) = world.get::<&mut EntityTransform>(entity) {
+                        et.world_mut().position = snapped;
+                    }
+                }
+            }
+        }
+
+        CollapsingHeader::new("OnRails")
+            .default_open(true)
+            .id_salt(format!("OnRails {}", entity.to_bits()))
+            .show(ui, |ui| {
+                ui.checkbox(&mut self.enabled, "Enabled");
+                ui.add_space(4.0);
+
+                let slider = ui.add(
+                    egui::Slider::new(&mut self.progress, 0.0..=1.0)
+                        .text("Progress")
+                        .min_decimals(2)
+                        .max_decimals(4),
+                );
+                if slider.changed() && n >= 2 {
+                    let pos = sample_path_linear(&self.path, self.progress);
+                    if let Ok(mut et) = world.get::<&mut EntityTransform>(entity) {
+                        et.world_mut().position = pos;
+                    }
+                }
+
+                ui.add_space(4.0);
+
+                let mut drive_tag: i32 = match &self.drive {
+                    RailDrive::Automatic { .. } => 0,
+                    RailDrive::FollowEntity { .. } => 1,
+                    RailDrive::AxisDriven { .. } => 2,
+                    RailDrive::Manual => 3,
+                };
+                let prev_tag = drive_tag;
+                ComboBox::new(format!("OnRails ComboBox {}", entity.to_bits()), "Drive")
+                    .selected_text(format!("{}", self.drive))
+                    .show_ui(ui, |ui| {
+                        ui.selectable_value(&mut drive_tag, 0, "Automatic");
+                        ui.selectable_value(&mut drive_tag, 1, "FollowEntity");
+                        ui.selectable_value(&mut drive_tag, 2, "AxisDriven");
+                        ui.selectable_value(&mut drive_tag, 3, "Manual");
+                    });
+                if drive_tag != prev_tag {
+                    self.drive = match drive_tag {
+                        0 => RailDrive::Automatic { speed: 0.1, looping: false },
+                        1 => RailDrive::FollowEntity { target: entity, monotonic: false },
+                        2 => RailDrive::AxisDriven {
+                            target: entity,
+                            axis: Vec3::X,
+                            range: (0.0, 100.0),
+                        },
+                        _ => RailDrive::Manual,
+                    };
+                }
+
+                ui.add_space(4.0);
+
+                match &mut self.drive {
+                    RailDrive::Automatic { speed, looping } => {
+                        ui.horizontal(|ui| {
+                            ui.label("Speed:");
+                            ui.add(egui::DragValue::new(speed).speed(0.001).range(0.0_f32..=f32::MAX));
+                        });
+                        ui.checkbox(looping, "Looping");
+                    }
+                    RailDrive::FollowEntity { target, monotonic } => {
+                        ui.label(format!("Target entity: {}", target.to_bits()));
+                        ui.checkbox(monotonic, "Monotonic");
+                    }
+                    RailDrive::AxisDriven { target, axis, range } => {
+                        ui.label(format!("Target entity: {}", target.to_bits()));
+                        ui.horizontal(|ui| {
+                            ui.label("Axis:");
+                            ui.add(egui::DragValue::new(&mut axis.x).speed(0.01).prefix("X:"));
+                            ui.add(egui::DragValue::new(&mut axis.y).speed(0.01).prefix("Y:"));
+                            ui.add(egui::DragValue::new(&mut axis.z).speed(0.01).prefix("Z:"));
+                        });
+                        ui.horizontal(|ui| {
+                            ui.label("Range:");
+                            ui.add(egui::DragValue::new(&mut range.0).speed(0.1).prefix("Min:"));
+                            ui.add(egui::DragValue::new(&mut range.1).speed(0.1).prefix("Max:"));
+                        });
+                    }
+                    RailDrive::Manual => {
+                        ui.label("Progress is set externally.");
+                    }
+                }
+
+                ui.add_space(4.0);
+
+                CollapsingHeader::new(format!("Path ({} points)", n))
+                    .default_open(false)
+                    .id_salt(format!("OnRails Path {}", entity.to_bits()))
+                    .show(ui, |ui| {
+                        let mut remove_idx: Option<usize> = None;
+                        for (i, point) in self.path.iter_mut().enumerate() {
+                            ui.horizontal(|ui| {
+                                ui.label(format!("[{}]", i));
+                                ui.add(egui::DragValue::new(&mut point.x).speed(0.1).prefix("X:"));
+                                ui.add(egui::DragValue::new(&mut point.y).speed(0.1).prefix("Y:"));
+                                ui.add(egui::DragValue::new(&mut point.z).speed(0.1).prefix("Z:"));
+                                if ui.small_button("-").clicked() {
+                                    remove_idx = Some(i);
+                                }
+                            });
+                        }
+                        if let Some(idx) = remove_idx {
+                            self.path.remove(idx);
+                        }
+                        if ui.button("+ Add Point").clicked() {
+                            let last = self.path.last().copied().unwrap_or(DVec3::ZERO);
+                            self.path.push(last + DVec3::new(1.0, 0.0, 0.0));
+                        }
+                    });
+
+                ui.add_space(4.0);
+                ui.checkbox(&mut self.debug, "Debug path");
+            });
+    }
+}
 
 #[typetag::serde]
 impl SerializedComponent for EntityTransform {}
@@ -27,15 +406,15 @@ impl Component for EntityTransform {
             internal: false,
             fqtn: "dropbear_engine::entity::EntityTransform".to_string(),
             type_name: "EntityTransform".to_string(),
-            category: None,
+            category: Some("Transform".to_string()),
             description: Some("Allows the entity to have a space within the world".to_string()),
         }
     }
 
-    fn init<'a>(
-        ser: &'a Self::SerializedForm,
+    fn init(
+        ser: &Self::SerializedForm,
         _: Arc<SharedGraphicsContext>,
-    ) -> crate::component::ComponentInitFuture<'a, Self> {
+    ) -> crate::component::ComponentInitFuture<Self> {
         Box::pin(async move { Ok((ser.clone(),)) })
     }
 
@@ -314,7 +693,7 @@ fn set_world_transform(
     ),
     c
 )]
-fn propogate_transform(
+fn propagate_transform(
     #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
     #[dropbear_macro::entity] entity: hecs::Entity,
 ) -> DropbearNativeResult<NTransform> {
@@ -325,3 +704,300 @@ fn propogate_transform(
         Err(DropbearNativeError::MissingComponent)
     }
 }
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "getEnabled"),
+    c
+)]
+fn on_rails_get_enabled(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+) -> DropbearNativeResult<bool> {
+    let rails = world.get::<&OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    Ok(rails.enabled)
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "setEnabled"),
+    c
+)]
+fn on_rails_set_enabled(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+    enabled: bool,
+) -> DropbearNativeResult<()> {
+    let mut rails = world.get::<&mut OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    rails.enabled = enabled;
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "existsForEntity"),
+    c
+)]
+fn on_rails_exists_for_entity(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+) -> DropbearNativeResult<bool> {
+    Ok(world.get::<&OnRails>(entity).is_ok())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "getProgress"),
+    c
+)]
+fn on_rails_get_progress(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+) -> DropbearNativeResult<f32> {
+    let rails = world.get::<&OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    Ok(rails.progress)
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "setProgress"),
+    c
+)]
+fn on_rails_set_progress(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+    progress: f32,
+) -> DropbearNativeResult<()> {
+    let mut rails = world.get::<&mut OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    rails.progress = progress.clamp(0.0, 1.0);
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "getPathLen"),
+    c
+)]
+fn on_rails_get_path_len(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+) -> DropbearNativeResult<i32> {
+    let rails = world.get::<&OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    Ok(rails.path.len() as i32)
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "getPathPoint"),
+    c
+)]
+fn on_rails_get_path_point(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+    index: i32,
+) -> DropbearNativeResult<NVector3> {
+    let rails = world.get::<&OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    let point = rails.path.get(index as usize).ok_or(DropbearNativeError::InvalidArgument)?;
+    Ok(NVector3::from(point))
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "clearPath"),
+    c
+)]
+fn on_rails_clear_path(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+) -> DropbearNativeResult<()> {
+    let mut rails = world.get::<&mut OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    rails.path.clear();
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "pushPathPoint"),
+    c
+)]
+fn on_rails_push_path_point(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+    point: &NVector3,
+) -> DropbearNativeResult<()> {
+    let mut rails = world.get::<&mut OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    rails.path.push(DVec3::from(point));
+    Ok(())
+}
+
+// `0` = Automatic, `1` = FollowEntity, `2` = AxisDriven, `3` = Manual.
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "getDriveType"),
+    c
+)]
+fn on_rails_get_drive_type(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+) -> DropbearNativeResult<i32> {
+    let rails = world.get::<&OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    let tag = match &rails.drive {
+        RailDrive::Automatic { .. }    => 0,
+        RailDrive::FollowEntity { .. } => 1,
+        RailDrive::AxisDriven { .. }   => 2,
+        RailDrive::Manual              => 3,
+    };
+    Ok(tag)
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "setDriveAutomatic"),
+    c
+)]
+fn on_rails_set_drive_automatic(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+    speed: f32,
+    looping: bool,
+) -> DropbearNativeResult<()> {
+    let mut rails = world.get::<&mut OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    rails.drive = RailDrive::Automatic { speed, looping };
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "setDriveFollowEntity"),
+    c
+)]
+fn on_rails_set_drive_follow_entity(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+    target: u64,
+    monotonic: bool,
+) -> DropbearNativeResult<()> {
+    let target_entity = hecs::Entity::from_bits(target).ok_or(DropbearNativeError::InvalidEntity)?;
+    let mut rails = world.get::<&mut OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    rails.drive = RailDrive::FollowEntity { target: target_entity, monotonic };
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "setDriveAxisDriven"),
+    c
+)]
+fn on_rails_set_drive_axis_driven(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+    target: u64,
+    axis: &NVector3,
+    range_min: f32,
+    range_max: f32,
+) -> DropbearNativeResult<()> {
+    let target_entity = hecs::Entity::from_bits(target).ok_or(DropbearNativeError::InvalidEntity)?;
+    let mut rails = world.get::<&mut OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    rails.drive = RailDrive::AxisDriven {
+        target: target_entity,
+        axis: Vec3::new(axis.x as f32, axis.y as f32, axis.z as f32),
+        range: (range_min, range_max),
+    };
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "setDriveManual"),
+    c
+)]
+fn on_rails_set_drive_manual(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+) -> DropbearNativeResult<()> {
+    let mut rails = world.get::<&mut OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    rails.drive = RailDrive::Manual;
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "getDriveAutomaticSpeed"),
+    c
+)]
+fn on_rails_get_drive_automatic_speed(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+) -> DropbearNativeResult<f32> {
+    let rails = world.get::<&OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    if let RailDrive::Automatic { speed, .. } = &rails.drive { Ok(*speed) } else { Err(DropbearNativeError::InvalidArgument) }
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "getDriveAutomaticLooping"),
+    c
+)]
+fn on_rails_get_drive_automatic_looping(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+) -> DropbearNativeResult<bool> {
+    let rails = world.get::<&OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    if let RailDrive::Automatic { looping, .. } = &rails.drive { Ok(*looping) } else { Err(DropbearNativeError::InvalidArgument) }
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "getDriveFollowEntityTarget"),
+    c
+)]
+fn on_rails_get_drive_follow_entity_target(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+) -> DropbearNativeResult<u64> {
+    let rails = world.get::<&OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    if let RailDrive::FollowEntity { target, .. } = &rails.drive { Ok(target.to_bits().get()) } else { Err(DropbearNativeError::InvalidArgument) }
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "getDriveFollowEntityMonotonic"),
+    c
+)]
+fn on_rails_get_drive_follow_entity_monotonic(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+) -> DropbearNativeResult<bool> {
+    let rails = world.get::<&OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    if let RailDrive::FollowEntity { monotonic, .. } = &rails.drive { Ok(*monotonic) } else { Err(DropbearNativeError::InvalidArgument) }
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "getDriveAxisDrivenTarget"),
+    c
+)]
+fn on_rails_get_drive_axis_driven_target(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+) -> DropbearNativeResult<u64> {
+    let rails = world.get::<&OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    if let RailDrive::AxisDriven { target, .. } = &rails.drive { Ok(target.to_bits().get()) } else { Err(DropbearNativeError::InvalidArgument) }
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "getDriveAxisDrivenAxis"),
+    c
+)]
+fn on_rails_get_drive_axis_driven_axis(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+) -> DropbearNativeResult<NVector3> {
+    let rails = world.get::<&OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    if let RailDrive::AxisDriven { axis, .. } = &rails.drive { Ok(NVector3::from(*axis)) } else { Err(DropbearNativeError::InvalidArgument) }
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "getDriveAxisDrivenRangeMin"),
+    c
+)]
+fn on_rails_get_drive_axis_driven_range_min(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+) -> DropbearNativeResult<f32> {
+    let rails = world.get::<&OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    if let RailDrive::AxisDriven { range, .. } = &rails.drive { Ok(range.0) } else { Err(DropbearNativeError::InvalidArgument) }
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.components.camera.OnRailsNative", func = "getDriveAxisDrivenRangeMax"),
+    c
+)]
+fn on_rails_get_drive_axis_driven_range_max(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+) -> DropbearNativeResult<f32> {
+    let rails = world.get::<&OnRails>(entity).map_err(|_| DropbearNativeError::MissingComponent)?;
+    if let RailDrive::AxisDriven { range, .. } = &rails.drive { Ok(range.1) } else { Err(DropbearNativeError::InvalidArgument) }
+}
diff --git a/crates/eucalyptus-core/src/utils.rs b/crates/eucalyptus-core/src/utils.rs
index 211a2aa..d716e70 100644
--- a/crates/eucalyptus-core/src/utils.rs
+++ b/crates/eucalyptus-core/src/utils.rs
@@ -766,6 +766,12 @@ macro_rules! ffi_error_return {
             }
         }
 
+        impl ErrorValue for f32 {
+            fn error_value() -> Self {
+                f32::NAN
+            }
+        }
+
         impl<T> ErrorValue for $crate::scripting::result::DropbearNativeResult<T> {
             fn error_value() -> Self {
                 Err($crate::scripting::native::DropbearNativeError::NullPointer)
diff --git a/crates/eucalyptus-editor/src/spawn.rs b/crates/eucalyptus-editor/src/spawn.rs
index da26abd..49bdcdb 100644
--- a/crates/eucalyptus-editor/src/spawn.rs
+++ b/crates/eucalyptus-editor/src/spawn.rs
@@ -4,6 +4,7 @@ use dropbear_engine::entity::{EntityTransform, MeshRenderer};
 use dropbear_engine::future::{FutureHandle, FutureQueue};
 use dropbear_engine::graphics::SharedGraphicsContext;
 use dropbear_engine::model::Model;
+use eucalyptus_core::component::ComponentApply;
 use eucalyptus_core::hierarchy::{Hierarchy, Parent};
 use eucalyptus_core::scene::SceneEntity;
 use eucalyptus_core::states::Label;
@@ -58,9 +59,7 @@ impl PendingSpawnController for Editor {
                 let graphics = graphics.clone();
 
                 let future = async move {
-                    let mut appliers: Vec<
-                        Box<dyn for<'a> FnOnce(&'a mut EntityBuilder) + Send + Sync>,
-                    > = Vec::new();
+                    let mut appliers: Vec<Box<dyn ComponentApply + Send + Sync>> = Vec::new();
                     for component in components {
                         if component.as_any().downcast_ref::<Parent>().is_some() {
                             continue;
@@ -80,7 +79,7 @@ impl PendingSpawnController for Editor {
                     Ok::<
                         (
                             Label,
-                            Vec<Box<dyn for<'a> FnOnce(&'a mut EntityBuilder) + Send + Sync>>,
+                            Vec<Box<dyn ComponentApply + Send + Sync>>,
                         ),
                         anyhow::Error,
                     >((label, appliers))
@@ -94,14 +93,14 @@ impl PendingSpawnController for Editor {
                 if let Some(result) = queue.exchange_owned(handle) {
                     if let Ok(r) = result.downcast::<anyhow::Result<(
                         Label,
-                        Vec<Box<dyn for<'a> FnOnce(&'a mut EntityBuilder) + Send + Sync>>,
+                        Vec<Box<dyn ComponentApply + Send + Sync>>,
                     )>>() {
                         match Arc::try_unwrap(r) {
                             Ok(Ok((label, appliers))) => {
                                 let mut builder = EntityBuilder::new();
                                 builder.add(label.clone());
                                 for applier in appliers {
-                                    applier(&mut builder);
+                                    applier.apply_to_builder(&mut builder);
                                 }
 
                                 let entity = self.world.spawn(builder.build());
@@ -110,7 +109,7 @@ impl PendingSpawnController for Editor {
                                         self.world.insert_one(entity, EntityTransform::default());
                                 }
 
-                                // Attach to parent if requested.
+                                // attach to parent
                                 if let Some(ref parent_label) = spawn.parent_label {
                                     let parent_entity = self
                                         .world
@@ -162,12 +161,10 @@ impl PendingSpawnController for Editor {
         let mut completed_components = Vec::new();
         for (index, (entity, handle)) in self.pending_components.iter().enumerate() {
             if let Some(result) = queue.exchange_owned(handle) {
-                if let Ok(r) = result.downcast::<anyhow::Result<Box<dyn for<'a> FnOnce(&'a mut EntityBuilder) + Send + Sync>>>() {
+                if let Ok(r) = result.downcast::<anyhow::Result<Box<dyn ComponentApply + Send + Sync>>>() {
                     match Arc::try_unwrap(r) {
                         Ok(Ok(applier)) => {
-                            let mut builder = EntityBuilder::new();
-                            applier(&mut builder);
-                            if let Err(e) = self.world.insert(*entity, builder.build()) {
+                            if let Err(e) = applier.apply_to_existing_entity(&mut self.world, *entity) {
                                 fatal!("Failed to add component bundle: {}", e);
                             } else {
                                 success!("Added component to entity {:?}", entity);
diff --git a/crates/kino-ui/src/widgets/mod.rs b/crates/kino-ui/src/widgets/mod.rs
index 77dc816..a1812c6 100644
--- a/crates/kino-ui/src/widgets/mod.rs
+++ b/crates/kino-ui/src/widgets/mod.rs
@@ -79,3 +79,43 @@ impl Border {
         Self { colour, width }
     }
 }
+
+/// States the different types of widgets.
+///
+/// Also serves as a little task checklist for this library.
+///
+/// Also it was ripped from the egui library.
+pub enum WidgetType {
+    Label,
+
+    TextEdit,
+
+    Button,
+
+    Checkbox,
+
+    RadioButton,
+
+    SelectableLabel,
+
+    ComboBox,
+
+    Slider,
+
+    DragValue,
+
+    ColourButton,
+
+    Image,
+
+    CollapsingHeader,
+
+    Panel,
+
+    ProgressIndicator,
+
+    /// If you cannot fit any of the above slots.
+    ///
+    /// If this is something you think should be added, file an issue.
+    Other,
+}
diff --git a/crates/magna-carta/src/generator/jvm.rs b/crates/magna-carta/src/generator/jvm.rs
index bc87cf9..1552f46 100644
--- a/crates/magna-carta/src/generator/jvm.rs
+++ b/crates/magna-carta/src/generator/jvm.rs
@@ -110,6 +110,21 @@ impl Generator for KotlinJVMGenerator {
 
         writeln!(output, "}}")?;
 
+        writeln!(output)?;
+        writeln!(output, "object ComponentManager {{")?;
+        writeln!(output, "    @Synchronized")?;
+        writeln!(output, "    fun registerAll() {{")?;
+        for component in manifest.components() {
+            writeln!(
+                output,
+                "        com.dropbear.ecs.registerKotlinComponentType({fqcn}, {simple}, null, null)",
+                fqcn = format!("\"{}\"", component.fqcn()),
+                simple = format!("\"{}\"", component.simple_name()),
+            )?;
+        }
+        writeln!(output, "    }}")?;
+        writeln!(output, "}}")?;
+
         Ok(output)
     }
 }
diff --git a/crates/magna-carta/src/generator/native.rs b/crates/magna-carta/src/generator/native.rs
index b1d3f68..2a5e0a1 100644
--- a/crates/magna-carta/src/generator/native.rs
+++ b/crates/magna-carta/src/generator/native.rs
@@ -554,6 +554,20 @@ object ScriptManager {{
 
         writeln!(output, "}}")?;
 
+        writeln!(output)?;
+        writeln!(output, "object ComponentManager {{")?;
+        writeln!(output, "    fun registerAll() {{")?;
+        for component in manifest.components() {
+            writeln!(
+                output,
+                "        com.dropbear.ecs.registerKotlinComponentType({fqcn}, {simple}, null, null)",
+                fqcn = format!("\"{}\"", component.fqcn()),
+                simple = format!("\"{}\"", component.simple_name()),
+            )?;
+        }
+        writeln!(output, "    }}")?;
+        writeln!(output, "}}")?;
+
         // ADD CNAME FUNCTIONS HERE
         writeln!(
             output,
@@ -567,6 +581,7 @@ fun CPointer<ULongVar>.toLongArray(length: Int): LongArray {{
 
 @CName("dropbear_init")
 fun dropbear_native_init(dropbearContextPtr: CPointer<DropbearContext>?): Int {{
+    ComponentManager.registerAll()
     return ScriptManager.init(dropbearContextPtr)
 }}
 
diff --git a/crates/magna-carta/src/lib.rs b/crates/magna-carta/src/lib.rs
index 9ad5a5e..7d6bd29 100644
--- a/crates/magna-carta/src/lib.rs
+++ b/crates/magna-carta/src/lib.rs
@@ -18,6 +18,7 @@ struct AnnotationInfo<'a> {
 #[derive(Debug, Clone)]
 pub struct ScriptManifest {
     items: Vec<ManifestItem>,
+    components: Vec<ComponentManifestItem>,
 }
 
 impl Default for ScriptManifest {
@@ -28,7 +29,7 @@ impl Default for ScriptManifest {
 
 impl ScriptManifest {
     pub fn new() -> Self {
-        Self { items: Vec::new() }
+        Self { items: Vec::new(), components: Vec::new() }
     }
 
     pub fn add_item(&mut self, item: ManifestItem) {
@@ -38,6 +39,14 @@ impl ScriptManifest {
     pub fn items(&self) -> &[ManifestItem] {
         &self.items
     }
+
+    pub fn add_component(&mut self, item: ComponentManifestItem) {
+        self.components.push(item);
+    }
+
+    pub fn components(&self) -> &[ComponentManifestItem] {
+        &self.components
+    }
 }
 
 /// Represents a single script class. This struct contains all the necessary information to generate
@@ -126,6 +135,76 @@ impl KotlinProcessor {
         Ok(Self { parser })
     }
 
+    /// Processes the file for `@EcsComponent` annotations, returning a
+    /// [`ComponentManifestItem`] for every annotated class found.
+    pub fn process_file_for_components(
+        &mut self,
+        source_code: &str,
+        file_path: PathBuf,
+    ) -> anyhow::Result<Vec<ComponentManifestItem>> {
+        let tree = self
+            .parser
+            .parse(source_code, None)
+            .ok_or_else(|| anyhow::anyhow!("Failed to parse source code"))?;
+
+        let root_node = tree.root_node();
+        let package = self.extract_package(root_node, source_code)?;
+
+        let mut items = Vec::new();
+        let mut stack = vec![root_node];
+
+        while let Some(node) = stack.pop() {
+            if node.kind() == "class_declaration" {
+                if let Some(class_name) = self.class_name_from_node(node, source_code)? {
+                    if self.node_has_annotation(node, source_code, "EcsComponent")? {
+                        let fqcn = if package.is_empty() {
+                            class_name.clone()
+                        } else {
+                            format!("{}.{}", package, class_name)
+                        };
+                        items.push(ComponentManifestItem::new(
+                            fqcn,
+                            class_name,
+                            file_path.clone(),
+                        ));
+                    }
+                }
+            }
+
+            let mut cursor = node.walk();
+            for child in node.children(&mut cursor) {
+                stack.push(child);
+            }
+        }
+
+        Ok(items)
+    }
+
+    /// Returns `true` if the given class declaration node has an annotation with the given name.
+    fn node_has_annotation(
+        &self,
+        class_node: tree_sitter::Node,
+        source: &str,
+        annotation_name: &str,
+    ) -> anyhow::Result<bool> {
+        let Some(modifiers) = self.first_child_by_kind(class_node, "modifiers") else {
+            return Ok(false);
+        };
+
+        let mut mod_cursor = modifiers.walk();
+        for modifier in modifiers.children(&mut mod_cursor) {
+            if modifier.kind() != "annotation" {
+                continue;
+            }
+            let (name, _) = self.annotation_name_and_args(modifier, source)?;
+            if name.as_deref() == Some(annotation_name) {
+                return Ok(true);
+            }
+        }
+
+        Ok(false)
+    }
+
     /// Processes the file for `@Runnable` annotations, and check if that
     /// class inherits the `System()` abstract class.
     pub fn process_file(
@@ -741,6 +820,10 @@ pub fn visit_kotlin_files(
                 if let Some(item) = processor.process_file(&source_code, path.clone())? {
                     manifest.add_item(item);
                 }
+
+                for component in processor.process_file_for_components(&source_code, path.clone())? {
+                    manifest.add_component(component);
+                }
             }
         }
     }
diff --git a/crates/redback-runtime/src/lib.rs b/crates/redback-runtime/src/lib.rs
index 98bca60..0a9b875 100644
--- a/crates/redback-runtime/src/lib.rs
+++ b/crates/redback-runtime/src/lib.rs
@@ -16,9 +16,6 @@ use eucalyptus_core::command::COMMAND_BUFFER;
 use eucalyptus_core::component::ComponentRegistry;
 use eucalyptus_core::input::InputState;
 use eucalyptus_core::physics::PhysicsState;
-use eucalyptus_core::physics::collider::shader::ColliderInstanceRaw;
-use eucalyptus_core::physics::collider::shader::ColliderWireframePipeline;
-use eucalyptus_core::physics::collider::{ColliderShapeKey, WireframeGeometry};
 use eucalyptus_core::ptr::{
     CommandBufferPtr, GraphicsContextPtr, InputStatePtr, PhysicsStatePtr, UiBufferPtr, WorldPtr,
 };
@@ -139,7 +136,6 @@ pub struct PlayMode {
     shader_globals: Option<GlobalsUniform>,
     instance_buffer_cache: HashMap<u64, ResizableBuffer<InstanceRaw>>,
     animated_instance_buffers: HashMap<Entity, ResizableBuffer<InstanceRaw>>,
-    collider_wireframe_pipeline: Option<ColliderWireframePipeline>,
     sky_pipeline: Option<SkyPipeline>,
     camera_bind_group: Option<wgpu::BindGroup>,
     default_skinning_buffer: Option<wgpu::Buffer>,
@@ -173,8 +169,6 @@ pub struct PlayMode {
     collision_force_event_receiver: Option<std::sync::mpsc::Receiver<ContactForceEvent>>,
     event_collector: ChannelEventCollector,
 
-    collider_wireframe_geometry_cache: HashMap<ColliderShapeKey, WireframeGeometry>,
-    collider_instance_buffer: Option<ResizableBuffer<ColliderInstanceRaw>>,
     viewport_offset: (f32, f32),
 
     // ui
@@ -221,9 +215,6 @@ impl PlayMode {
             physics_state: Box::new(PhysicsState::new()),
             pending_physics_state: Default::default(),
             physics_receiver: Default::default(),
-            collider_wireframe_pipeline: None,
-            collider_wireframe_geometry_cache: HashMap::new(),
-            collider_instance_buffer: None,
             viewport_offset: (0.0, 0.0),
             collision_event_receiver: Some(ce_r),
             collision_force_event_receiver: Some(cfe_r),
@@ -264,7 +255,6 @@ impl PlayMode {
         self.light_cube_pipeline = None;
         self.main_pipeline = None;
         self.shader_globals = None;
-        self.collider_wireframe_pipeline = None;
         self.kino = None;
         self.sky_pipeline = None;
         self.camera_bind_group = None;
@@ -288,7 +278,6 @@ impl PlayMode {
             graphics.clone(),
             Some("runtime shader globals"),
         ));
-        self.collider_wireframe_pipeline = Some(ColliderWireframePipeline::new(graphics.clone()));
 
         let mut camera_bind_group = None;
         let mut pending_sky_pipeline = None;
diff --git a/crates/redback-runtime/src/scene.rs b/crates/redback-runtime/src/scene.rs
index df89924..267793e 100644
--- a/crates/redback-runtime/src/scene.rs
+++ b/crates/redback-runtime/src/scene.rs
@@ -17,10 +17,7 @@ use eucalyptus_core::entity_status::EntityStatus;
 use eucalyptus_core::command::CommandBufferPoller;
 use eucalyptus_core::egui::CentralPanel;
 use eucalyptus_core::hierarchy::{EntityTransformExt, Parent};
-use eucalyptus_core::physics::collider::ColliderGroup;
-use eucalyptus_core::physics::collider::ColliderShapeKey;
-use eucalyptus_core::physics::collider::shader::ColliderInstanceRaw;
-use eucalyptus_core::physics::collider::shader::create_wireframe_geometry;
+use eucalyptus_core::physics::collider::{ColliderGroup, ColliderShape};
 use eucalyptus_core::physics::kcc::KCC;
 use eucalyptus_core::rapier3d::geometry::SharedShape;
 use eucalyptus_core::rapier3d::prelude::QueryFilter;
@@ -28,7 +25,7 @@ use eucalyptus_core::scene::loading::{IsSceneLoaded, SCENE_LOADER, SceneLoadResu
 use eucalyptus_core::states::SCENES;
 use eucalyptus_core::states::{Label, PROJECT};
 use eucalyptus_core::ui::HUDComponent;
-use glam::{DVec3, Mat4, Quat, Vec2};
+use glam::{DVec3, Mat4, Quat, Vec2, Vec3};
 use hecs::Entity;
 use kino_ui::WidgetTree;
 use kino_ui::rendering::KinoRenderTargetId;
@@ -1204,7 +1201,7 @@ impl Scene for PlayMode {
             render_pass.draw(0..3, 0..1);
         }
 
-        // collider pipeline
+        // collider debug draw
         {
             let show_hitboxes = self
                 .current_scene
@@ -1219,117 +1216,71 @@ impl Scene for PlayMode {
                 .unwrap_or(false);
 
             if show_hitboxes {
-                puffin::profile_scope!("collider wireframe pipeline");
-                if let Some(collider_pipeline) = &self.collider_wireframe_pipeline {
-                    log_once::debug_once!("Found collider wireframe pipeline");
-                    let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                        label: Some("collider wireframe render pass"),
-                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
-                            view: hdr.render_view(),
-                            depth_slice: None,
-                            resolve_target: hdr.resolve_target(),
-                            ops: wgpu::Operations {
-                                load: wgpu::LoadOp::Load,
-                                store: wgpu::StoreOp::Store,
-                            },
-                        })],
-                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
-                            view: &graphics.depth_texture.view,
-                            depth_ops: Some(wgpu::Operations {
-                                load: wgpu::LoadOp::Load,
-                                store: wgpu::StoreOp::Store,
-                            }),
-                            stencil_ops: None,
-                        }),
-                        occlusion_query_set: None,
-                        timestamp_writes: None,
-                    });
-
-                    render_pass.set_pipeline(&collider_pipeline.pipeline);
-                    render_pass.set_bind_group(0, camera_bind_group, &[]);
-
-                    let mut instances_by_shape: HashMap<
-                        ColliderShapeKey,
-                        Vec<ColliderInstanceRaw>,
-                    > = HashMap::new();
-
+                puffin::profile_scope!("collider debug draw");
+                if let Some(debug_draw) = graphics.debug_draw.lock().as_mut() {
+                    let colour = [0.0, 1.0, 0.0, 1.0];
                     let mut q = self.world.query::<(&EntityTransform, &ColliderGroup)>();
                     for (entity_transform, group) in q.iter() {
                         for collider in &group.colliders {
                             let world_tf = entity_transform.sync();
-
                             let entity_matrix = world_tf.matrix().as_mat4();
-
                             let offset_transform = Transform::new()
                                 .with_offset(collider.translation, collider.rotation);
                             let offset_matrix = offset_transform.matrix().as_mat4();
-
                             let final_matrix = entity_matrix * offset_matrix;
-
-                            let color = [0.0, 1.0, 0.0, 1.0];
-                            let instance = ColliderInstanceRaw::from_matrix(final_matrix, color);
-
-                            let key = ColliderShapeKey::from(&collider.shape);
-                            instances_by_shape.entry(key).or_default().push(instance);
-
-                            self.collider_wireframe_geometry_cache
-                                .entry(key)
-                                .or_insert_with(|| {
-                                    create_wireframe_geometry(graphics.clone(), &collider.shape)
-                                });
-                        }
-                    }
-
-                    if !instances_by_shape.is_empty() {
-                        let total_instances: usize =
-                            instances_by_shape.values().map(|v| v.len()).sum();
-                        let mut all_instances = Vec::with_capacity(total_instances);
-                        let mut draws: Vec<(ColliderShapeKey, usize, usize)> = Vec::new();
-
-                        for (key, instances) in instances_by_shape {
-                            let start = all_instances.len();
-                            all_instances.extend_from_slice(&instances);
-                            let count = instances.len();
-                            draws.push((key, start, count));
-                        }
-
-                        let instance_buffer =
-                            self.collider_instance_buffer.get_or_insert_with(|| {
-                                ResizableBuffer::new(
-                                    &graphics.device,
-                                    all_instances.len().max(10),
-                                    wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-                                    "Collider Instance Buffer",
-                                )
-                            });
-                        instance_buffer.write(&graphics.device, &graphics.queue, &all_instances);
-
-                        for (key, start, count) in draws {
-                            let Some(geometry) = self.collider_wireframe_geometry_cache.get(&key)
-                            else {
-                                continue;
-                            };
-
-                            let start_bytes = (start * std::mem::size_of::<ColliderInstanceRaw>())
-                                as wgpu::BufferAddress;
-                            let end_bytes = ((start + count)
-                                * std::mem::size_of::<ColliderInstanceRaw>())
-                                as wgpu::BufferAddress;
-
-                            render_pass.set_vertex_buffer(
-                                1,
-                                instance_buffer.buffer().slice(start_bytes..end_bytes),
-                            );
-                            render_pass.set_vertex_buffer(0, geometry.vertex_buffer.slice(..));
-                            render_pass.set_index_buffer(
-                                geometry.index_buffer.slice(..),
-                                wgpu::IndexFormat::Uint16,
-                            );
-                            render_pass.draw_indexed(0..geometry.index_count, 0, 0..count as u32);
+                            let (scale, rotation, translation) =
+                                final_matrix.to_scale_rotation_translation();
+
+                            match &collider.shape {
+                                ColliderShape::Box { half_extents } => {
+                                    let he = Vec3::new(
+                                        half_extents.x as f32 * scale.x,
+                                        half_extents.y as f32 * scale.y,
+                                        half_extents.z as f32 * scale.z,
+                                    );
+                                    debug_draw.draw_obb(translation, he, rotation, colour);
+                                }
+                                ColliderShape::Sphere { radius } => {
+                                    let r = radius * scale.x.max(scale.y).max(scale.z);
+                                    debug_draw.draw_sphere(translation, r, colour);
+                                }
+                                ColliderShape::Capsule { half_height, radius } => {
+                                    let axis = rotation * Vec3::Y;
+                                    let top = translation + axis * (half_height * scale.y);
+                                    let bottom = translation - axis * (half_height * scale.y);
+                                    debug_draw.draw_capsule(
+                                        bottom,
+                                        top,
+                                        radius * scale.x.max(scale.z),
+                                        colour,
+                                    );
+                                }
+                                ColliderShape::Cylinder { half_height, radius } => {
+                                    let axis = rotation * Vec3::Y;
+                                    debug_draw.draw_cylinder(
+                                        translation,
+                                        half_height * scale.y,
+                                        radius * scale.x.max(scale.z),
+                                        axis,
+                                        colour,
+                                    );
+                                }
+                                ColliderShape::Cone { half_height, radius } => {
+                                    let axis = rotation * Vec3::Y;
+                                    let apex = translation + axis * (half_height * scale.y);
+                                    let height = 2.0 * half_height * scale.y;
+                                    let r = radius * scale.x.max(scale.z);
+                                    debug_draw.draw_cone(
+                                        apex,
+                                        -(rotation * Vec3::Y),
+                                        (r / height).atan(),
+                                        height,
+                                        colour,
+                                    );
+                                }
+                            }
                         }
                     }
-                } else {
-                    log_once::warn_once!("No collider pipeline found");
                 }
             }
         }
diff --git a/include/dropbear.h b/include/dropbear.h
index 30df989..a0cf32d 100644
--- a/include/dropbear.h
+++ b/include/dropbear.h
@@ -520,17 +520,18 @@ int32_t dropbear_collider_set_collider_restitution(PhysicsStatePtr physics, cons
 int32_t dropbear_collider_set_collider_rotation(PhysicsStatePtr physics, const NCollider* collider, const NVector3* rotation);
 int32_t dropbear_collider_set_collider_shape(PhysicsStatePtr physics, const NCollider* collider, const ColliderShape* shape);
 int32_t dropbear_collider_set_collider_translation(PhysicsStatePtr physics, const NCollider* collider, const NVector3* translation);
-int32_t dropbear_debug_debug_draw_aabb(GraphicsContextPtr graphics, const NVector3* min, const NVector3* max, const NColour* colour);
-int32_t dropbear_debug_debug_draw_arrow(GraphicsContextPtr graphics, const NVector3* start, const NVector3* end, const NColour* colour);
-int32_t dropbear_debug_debug_draw_capsule(GraphicsContextPtr graphics, const NVector3* a, const NVector3* b, float radius, const NColour* colour);
-int32_t dropbear_debug_debug_draw_circle(GraphicsContextPtr graphics, const NVector3* center, float radius, const NVector3* normal, const NColour* colour);
-int32_t dropbear_debug_debug_draw_cone(GraphicsContextPtr graphics, const NVector3* apex, const NVector3* dir, float angle, float length, const NColour* colour);
-int32_t dropbear_debug_debug_draw_globe(GraphicsContextPtr graphics, const NVector3* center, float radius, uint32_t lat_lines, uint32_t lon_lines, const NColour* colour);
-int32_t dropbear_debug_debug_draw_line(GraphicsContextPtr graphics, const NVector3* start, const NVector3* end, const NColour* colour);
-int32_t dropbear_debug_debug_draw_obb(GraphicsContextPtr graphics, const NVector3* center, const NVector3* half_extents, const NQuaternion* rotation, const NColour* colour);
-int32_t dropbear_debug_debug_draw_point(GraphicsContextPtr graphics, const NVector3* pos, float size, const NColour* colour);
-int32_t dropbear_debug_debug_draw_ray(GraphicsContextPtr graphics, const NVector3* origin, const NVector3* dir, const NColour* colour);
-int32_t dropbear_debug_debug_draw_sphere(GraphicsContextPtr graphics, const NVector3* center, float radius, const NColour* colour);
+int32_t dropbear_debug_draw_aabb(GraphicsContextPtr graphics, const NVector3* min, const NVector3* max, const NColour* colour);
+int32_t dropbear_debug_draw_arrow(GraphicsContextPtr graphics, const NVector3* start, const NVector3* end, const NColour* colour);
+int32_t dropbear_debug_draw_capsule(GraphicsContextPtr graphics, const NVector3* a, const NVector3* b, float radius, const NColour* colour);
+int32_t dropbear_debug_draw_circle(GraphicsContextPtr graphics, const NVector3* center, float radius, const NVector3* normal, const NColour* colour);
+int32_t dropbear_debug_draw_cone(GraphicsContextPtr graphics, const NVector3* apex, const NVector3* dir, float angle, float length, const NColour* colour);
+int32_t dropbear_debug_draw_cylinder(GraphicsContextPtr graphics, const NVector3* center, float half_height, float radius, const NVector3* axis, const NColour* colour);
+int32_t dropbear_debug_draw_globe(GraphicsContextPtr graphics, const NVector3* center, float radius, uint32_t lat_lines, uint32_t lon_lines, const NColour* colour);
+int32_t dropbear_debug_draw_line(GraphicsContextPtr graphics, const NVector3* start, const NVector3* end, const NColour* colour);
+int32_t dropbear_debug_draw_obb(GraphicsContextPtr graphics, const NVector3* center, const NVector3* half_extents, const NQuaternion* rotation, const NColour* colour);
+int32_t dropbear_debug_draw_point(GraphicsContextPtr graphics, const NVector3* pos, float size, const NColour* colour);
+int32_t dropbear_debug_draw_ray(GraphicsContextPtr graphics, const NVector3* origin, const NVector3* dir, const NColour* colour);
+int32_t dropbear_debug_draw_sphere(GraphicsContextPtr graphics, const NVector3* center, float radius, const NColour* colour);
 int32_t dropbear_engine_get_asset(AssetRegistryPtr asset, const char* label, const AssetKind* kind, uint64_t* out0, bool* out0_present);
 int32_t dropbear_engine_get_entity(WorldPtr world, const char* label, uint64_t* out0);
 int32_t dropbear_engine_quit(CommandBufferPtr command_buffer);
@@ -640,7 +641,29 @@ int32_t dropbear_scripting_switch_to_scene_immediate(CommandBufferPtr command_bu
 int32_t dropbear_transform_exists_for_entity(WorldPtr world, uint64_t entity, bool* out0);
 int32_t dropbear_transform_get_local_transform(WorldPtr world, uint64_t entity, NTransform* out0);
 int32_t dropbear_transform_get_world_transform(WorldPtr world, uint64_t entity, NTransform* out0);
-int32_t dropbear_transform_propogate_transform(WorldPtr world, uint64_t entity, NTransform* out0);
+int32_t dropbear_transform_on_rails_clear_path(WorldPtr world, uint64_t entity);
+int32_t dropbear_transform_on_rails_exists_for_entity(WorldPtr world, uint64_t entity, bool* out0);
+int32_t dropbear_transform_on_rails_get_drive_automatic_looping(WorldPtr world, uint64_t entity, bool* out0);
+int32_t dropbear_transform_on_rails_get_drive_automatic_speed(WorldPtr world, uint64_t entity, float* out0);
+int32_t dropbear_transform_on_rails_get_drive_axis_driven_axis(WorldPtr world, uint64_t entity, NVector3* out0);
+int32_t dropbear_transform_on_rails_get_drive_axis_driven_range_max(WorldPtr world, uint64_t entity, float* out0);
+int32_t dropbear_transform_on_rails_get_drive_axis_driven_range_min(WorldPtr world, uint64_t entity, float* out0);
+int32_t dropbear_transform_on_rails_get_drive_axis_driven_target(WorldPtr world, uint64_t entity, uint64_t* out0);
+int32_t dropbear_transform_on_rails_get_drive_follow_entity_monotonic(WorldPtr world, uint64_t entity, bool* out0);
+int32_t dropbear_transform_on_rails_get_drive_follow_entity_target(WorldPtr world, uint64_t entity, uint64_t* out0);
+int32_t dropbear_transform_on_rails_get_drive_type(WorldPtr world, uint64_t entity, int32_t* out0);
+int32_t dropbear_transform_on_rails_get_enabled(WorldPtr world, uint64_t entity, bool* out0);
+int32_t dropbear_transform_on_rails_get_path_len(WorldPtr world, uint64_t entity, int32_t* out0);
+int32_t dropbear_transform_on_rails_get_path_point(WorldPtr world, uint64_t entity, int32_t index, NVector3* out0);
+int32_t dropbear_transform_on_rails_get_progress(WorldPtr world, uint64_t entity, float* out0);
+int32_t dropbear_transform_on_rails_push_path_point(WorldPtr world, uint64_t entity, const NVector3* point);
+int32_t dropbear_transform_on_rails_set_drive_automatic(WorldPtr world, uint64_t entity, float speed, bool looping);
+int32_t dropbear_transform_on_rails_set_drive_axis_driven(WorldPtr world, uint64_t entity, uint64_t target, const NVector3* axis, float range_min, float range_max);
+int32_t dropbear_transform_on_rails_set_drive_follow_entity(WorldPtr world, uint64_t entity, uint64_t target, bool monotonic);
+int32_t dropbear_transform_on_rails_set_drive_manual(WorldPtr world, uint64_t entity);
+int32_t dropbear_transform_on_rails_set_enabled(WorldPtr world, uint64_t entity, bool enabled);
+int32_t dropbear_transform_on_rails_set_progress(WorldPtr world, uint64_t entity, float progress);
+int32_t dropbear_transform_propagate_transform(WorldPtr world, uint64_t entity, NTransform* out0);
 int32_t dropbear_transform_set_local_transform(WorldPtr world, uint64_t entity, const NTransform* transform);
 int32_t dropbear_transform_set_world_transform(WorldPtr world, uint64_t entity, const NTransform* transform);
 
diff --git a/scripting/commonMain/kotlin/com/dropbear/DropbearEngine.kt b/scripting/commonMain/kotlin/com/dropbear/DropbearEngine.kt
index 8e3be3f..aa363c2 100644
--- a/scripting/commonMain/kotlin/com/dropbear/DropbearEngine.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/DropbearEngine.kt
@@ -8,6 +8,8 @@ import com.dropbear.input.InputState
 import com.dropbear.scene.SceneManager
 import com.dropbear.ui.UIInstruction
 import com.dropbear.ui.UIInstructionSet
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
 
 internal var exceptionOnError: Boolean = false
 var lastErrorMessage: String? = null
@@ -45,7 +47,12 @@ class DropbearEngine(val native: NativeEngine) {
     }
 
     fun getAssetEntry(displayName: String): AssetEntry {
+        TODO("getAssetEntry not yet implemented")
+    }
 
+    @OptIn(ExperimentalUuidApi::class)
+    fun getAssetEntry(uuid: Uuid): AssetEntry {
+        TODO("getAssetEntry not yet implemented")
     }
 
     /**
diff --git a/scripting/commonMain/kotlin/com/dropbear/animation/AnimationComponent.kt b/scripting/commonMain/kotlin/com/dropbear/animation/AnimationComponent.kt
index c35c2e4..4c08d97 100644
--- a/scripting/commonMain/kotlin/com/dropbear/animation/AnimationComponent.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/animation/AnimationComponent.kt
@@ -1,12 +1,10 @@
 package com.dropbear.animation
 
 import com.dropbear.EntityId
-import com.dropbear.components.Camera
-import com.dropbear.components.cameraExistsForEntity
-import com.dropbear.ecs.Component
 import com.dropbear.ecs.ComponentType
+import com.dropbear.ecs.ExternalComponent
 
-class AnimationComponent(val parentEntity: EntityId) : Component(parentEntity, "AnimationComponent") {
+class AnimationComponent(val parentEntity: EntityId) : ExternalComponent("dropbear_engine::animation::AnimationComponent") {
     val availableAnimations: List<String>
         get() = getAvailableAnimations()
     
diff --git a/scripting/commonMain/kotlin/com/dropbear/components/Camera.kt b/scripting/commonMain/kotlin/com/dropbear/components/Camera.kt
index 2e9a05b..8d8c8df 100644
--- a/scripting/commonMain/kotlin/com/dropbear/components/Camera.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/components/Camera.kt
@@ -1,8 +1,8 @@
 package com.dropbear.components
 
 import com.dropbear.EntityId
-import com.dropbear.ecs.Component
 import com.dropbear.ecs.ComponentType
+import com.dropbear.ecs.ExternalComponent
 import com.dropbear.math.Vector3d
 
 /**
@@ -10,9 +10,8 @@ import com.dropbear.math.Vector3d
  */
 class Camera(
     internal val entity: EntityId,
-): Component(
+): ExternalComponent(
     fullyQualifiedTypeName = "dropbear_engine::camera::Camera",
-    typeName = "Camera3D",
 ) {
     /**
      * The eye/position of the camera.
@@ -136,6 +135,8 @@ class Camera(
     }
 }
 
+internal expect fun cameraExistsForEntity(entity: EntityId): Boolean
+
 internal expect fun Camera.getCameraEye(entity: EntityId): Vector3d
 internal expect fun Camera.setCameraEye(entity: EntityId, value: Vector3d)
 internal expect fun Camera.getCameraTarget(entity: EntityId): Vector3d
@@ -156,6 +157,4 @@ internal expect fun Camera.setCameraPitch(entity: EntityId, value: Double)
 internal expect fun Camera.getCameraSpeed(entity: EntityId): Double
 internal expect fun Camera.setCameraSpeed(entity: EntityId, value: Double)
 internal expect fun Camera.getCameraSensitivity(entity: EntityId): Double
-internal expect fun Camera.setCameraSensitivity(entity: EntityId, value: Double)
-
-internal expect fun cameraExistsForEntity(entity: EntityId): Boolean
\ No newline at end of file
+internal expect fun Camera.setCameraSensitivity(entity: EntityId, value: Double)
\ No newline at end of file
diff --git a/scripting/commonMain/kotlin/com/dropbear/components/CustomProperties.kt b/scripting/commonMain/kotlin/com/dropbear/components/CustomProperties.kt
index 9ab5dc7..e807ffb 100644
--- a/scripting/commonMain/kotlin/com/dropbear/components/CustomProperties.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/components/CustomProperties.kt
@@ -1,8 +1,8 @@
 package com.dropbear.components
 
 import com.dropbear.EntityId
-import com.dropbear.ecs.Component
 import com.dropbear.ecs.ComponentType
+import com.dropbear.ecs.ExternalComponent
 import com.dropbear.math.Vector3d
 import com.dropbear.math.Vector3f
 import com.dropbear.math.Vector3i
@@ -12,9 +12,8 @@ import com.dropbear.math.Vector3i
  *
  * The entity must include the `CustomProperties` component in the editor.
  */
-class CustomProperties(val id: EntityId): Component(
+class CustomProperties(val id: EntityId): ExternalComponent(
     fullyQualifiedTypeName = "eucalyptus_core::properties::CustomProperties",
-    typeName = "CustomProperties",
 ) {
     /**
      * Fetches the property of the ModelProperty component on the entity.
diff --git a/scripting/commonMain/kotlin/com/dropbear/components/EntityTransform.kt b/scripting/commonMain/kotlin/com/dropbear/components/EntityTransform.kt
index 07f2947..a24bc32 100644
--- a/scripting/commonMain/kotlin/com/dropbear/components/EntityTransform.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/components/EntityTransform.kt
@@ -1,8 +1,8 @@
 package com.dropbear.components
 
 import com.dropbear.EntityId
-import com.dropbear.ecs.Component
 import com.dropbear.ecs.ComponentType
+import com.dropbear.ecs.ExternalComponent
 import com.dropbear.math.Transform
 
 /**
@@ -10,9 +10,8 @@ import com.dropbear.math.Transform
  *
  * This entity must contain the `EntityTransform` component to be queryable.
  */
-class EntityTransform(val id: EntityId): Component(
+class EntityTransform(val id: EntityId): ExternalComponent(
     fullyQualifiedTypeName = "dropbear_engine::entity::EntityTransform",
-    typeName = "EntityTransform",
 ) {
     /**
      * The local transform.
diff --git a/scripting/commonMain/kotlin/com/dropbear/components/Label.kt b/scripting/commonMain/kotlin/com/dropbear/components/Label.kt
index c24a09e..f7cbdf8 100644
--- a/scripting/commonMain/kotlin/com/dropbear/components/Label.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/components/Label.kt
@@ -2,8 +2,8 @@ package com.dropbear.components
 
 import com.dropbear.EntityId
 import com.dropbear.EntityRef
-import com.dropbear.ecs.Component
 import com.dropbear.ecs.ComponentType
+import com.dropbear.ecs.ExternalComponent
 import com.dropbear.getEntityLabel
 
 /**
@@ -15,9 +15,8 @@ import com.dropbear.getEntityLabel
  */
 class Label(
     internal val entity: EntityId
-): Component(
+): ExternalComponent(
     fullyQualifiedTypeName = "eucalyptus_core::states::Label",
-    typeName = "Label",
 ) {
     val name: String
         get() = EntityRef.getEntityLabel(entity)
diff --git a/scripting/commonMain/kotlin/com/dropbear/components/MeshRenderer.kt b/scripting/commonMain/kotlin/com/dropbear/components/MeshRenderer.kt
index 4d08c80..cf3c1ce 100644
--- a/scripting/commonMain/kotlin/com/dropbear/components/MeshRenderer.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/components/MeshRenderer.kt
@@ -3,19 +3,17 @@ package com.dropbear.components
 import com.dropbear.EntityId
 import com.dropbear.asset.ModelHandle
 import com.dropbear.asset.TextureHandle
-import com.dropbear.ecs.Component
 import com.dropbear.ecs.ComponentType
+import com.dropbear.ecs.ExternalComponent
 
 /**
  * A component that allows for a 3D model to be rendered.
  *
  * It must require the `MeshRenderer` component in the editor to be queryable.
  */
-class MeshRenderer(val id: EntityId) : Component(
+class MeshRenderer(val id: EntityId) : ExternalComponent(
     fullyQualifiedTypeName = "dropbear_engine::entity::MeshRenderer",
-    typeName = "MeshRenderer",
 ) {
-
     /**
      * The active model currently assigned to this entity.
      * Setting this to null removes the model.
diff --git a/scripting/commonMain/kotlin/com/dropbear/components/camera/OnRails.kt b/scripting/commonMain/kotlin/com/dropbear/components/camera/OnRails.kt
new file mode 100644
index 0000000..cdc5736
--- /dev/null
+++ b/scripting/commonMain/kotlin/com/dropbear/components/camera/OnRails.kt
@@ -0,0 +1,137 @@
+package com.dropbear.components.camera
+
+import com.dropbear.EntityId
+import com.dropbear.ecs.ComponentType
+import com.dropbear.ecs.ExternalComponent
+import com.dropbear.math.Vector3d
+import com.dropbear.math.Vector3f
+
+/**
+ * Controls how the [OnRails] component advances its progress along the path.
+ */
+sealed class RailDrive {
+    /**
+     * Progress advances automatically at a fixed speed.
+     *
+     * Use for cutscenes, intros, or automated camera pans.
+     *
+     * @param speed Units of progress (0.0–1.0) advanced per second.
+     * @param looping When `true`, progress wraps back to 0.0 at the end.
+     */
+    data class Automatic(
+        val speed: Float,
+        val looping: Boolean,
+    ) : RailDrive()
+
+    /**
+     * Progress is tied to the closest point on the rail to a target entity.
+     *
+     * Use for third-person or side-scroller cameras following a player.
+     *
+     * @param target The entity whose position drives rail progress.
+     * @param monotonic When `true`, progress can never decrease. The camera won't scroll backward.
+     */
+    data class FollowEntity(
+        val target: EntityId,
+        val monotonic: Boolean = false,
+    ) : RailDrive()
+
+    /**
+     * Progress is driven by a specific axis of the target entity's world position.
+     *
+     * e.g. `AxisDriven(player, Vector3f(1f, 0f, 0f), 0f, 100f)` scrolls as the player moves
+     * along the X axis from 0 to 100 world units.
+     *
+     * Use for side-scrollers or top-down cameras with a dominant movement axis.
+     *
+     * @param target The entity whose position is projected onto [axis].
+     * @param axis World-space direction vector to project onto.
+     * @param rangeMin World-space axis value that maps to progress 0.0.
+     * @param rangeMax World-space axis value that maps to progress 1.0.
+     */
+    data class AxisDriven(
+        val target: EntityId,
+        val axis: Vector3f,
+        val rangeMin: Float,
+        val rangeMax: Float,
+    ) : RailDrive()
+
+    /**
+     * Progress is set entirely by external code (scripting, cutscene sequencer, etc.).
+     *
+     * The system will not touch [OnRails.progress] at all.
+     */
+    data object Manual : RailDrive()
+}
+
+/**
+ * Constrains an entity's movement to a fixed spline path.
+ *
+ * The entity must also have an `EntityTransform` component. [progress] is a value in `[0.0, 1.0]`
+ * that indicates how far along the path the entity sits; the native system updates `world.position`
+ * every frame based on the active [drive] mode.
+ *
+ * @param entity The entity this component is attached to.
+ */
+class OnRails(
+    val entity: EntityId,
+) : ExternalComponent("eucalyptus_core::transform::OnRails") {
+
+    /**
+     * Whether the component actively moves the entity along the path each frame.
+     *
+     * When `false`, the system will not update the entity's position from the path; `progress`
+     * can still be read or written externally (e.g., for cutscene sequencers).
+     */
+    var enabled: Boolean
+        get() = onRailsGetEnabled()
+        set(value) = onRailsSetEnabled(value)
+
+    /**
+     * The ordered list of world-space waypoints that define the rail.
+     *
+     * Requires at least 2 points. Internally stored as `Vec<DVec3>` on the Rust side.
+     */
+    var path: List<Vector3d>
+        get() = onRailsGetPath()
+        set(value) = onRailsSetPath(value)
+
+    /**
+     * Current interpolation position along the rail, in the range `[0.0, 1.0]`.
+     *
+     * `0.0` is the first waypoint and `1.0` is the last. Can be set directly when using
+     * [RailDrive.Manual].
+     */
+    var progress: Float
+        get() = onRailsGetProgress()
+        set(value) = onRailsSetProgress(value)
+
+    /**
+     * How the rail progress is updated each frame.
+     *
+     * @see RailDrive
+     */
+    var drive: RailDrive
+        get() = onRailsGetDrive()
+        set(value) = onRailsSetDrive(value)
+
+    companion object : ComponentType<OnRails> {
+        override fun get(entityId: EntityId): OnRails? {
+            return if (onRailsExistsForEntity(entityId)) OnRails(entityId) else null
+        }
+    }
+}
+
+internal expect fun onRailsExistsForEntity(entityId: EntityId): Boolean
+
+internal expect fun OnRails.onRailsGetEnabled(): Boolean
+internal expect fun OnRails.onRailsSetEnabled(enabled: Boolean)
+
+internal expect fun OnRails.onRailsGetPath(): List<Vector3d>
+internal expect fun OnRails.onRailsSetPath(path: List<Vector3d>)
+
+internal expect fun OnRails.onRailsGetProgress(): Float
+internal expect fun OnRails.onRailsSetProgress(progress: Float)
+
+internal expect fun OnRails.onRailsGetDrive(): RailDrive
+internal expect fun OnRails.onRailsSetDrive(drive: RailDrive)
diff --git a/scripting/commonMain/kotlin/com/dropbear/components/camera/SpringyCameraController.kt b/scripting/commonMain/kotlin/com/dropbear/components/camera/SpringyCameraController.kt
index 218ee65..8311721 100644
--- a/scripting/commonMain/kotlin/com/dropbear/components/camera/SpringyCameraController.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/components/camera/SpringyCameraController.kt
@@ -1,8 +1,11 @@
 package com.dropbear.components.camera
 
+import com.dropbear.ecs.NativeComponent
 import com.dropbear.math.Vector3d
 import com.dropbear.physics.ColliderShape
 import com.dropbear.physics.Physics
+import com.dropbear.ui.Inspect
+import com.dropbear.ui.WidgetType
 
 /**
  * A springy camera that is used for any camera. It uses ray casting to check if the distance from the player to the
@@ -10,7 +13,11 @@ import com.dropbear.physics.Physics
  *
  * Inspired by Godot's SpringyCameraController (other engines probably have their own, never used them before).
  */
-class SpringyCameraController {
+class SpringyCameraController: NativeComponent(
+    fullyQualifiedTypeName = "com.dropbear.components.camera.SpringyCameraController",
+    typeName = "SpringyCameraController",
+) {
+    @Inspect(WidgetType.DragValue)
     private var currentDistance: Double = 5.0
     private val margin = 0.3
     private val sphereRadius = 0.2
@@ -47,4 +54,7 @@ class SpringyCameraController {
 
         return playerPos + (dir * currentDistance)
     }
+
+    override fun inspect() {}
+    override fun updateComponent() {}
 }
\ No newline at end of file
diff --git a/scripting/commonMain/kotlin/com/dropbear/ecs/Component.kt b/scripting/commonMain/kotlin/com/dropbear/ecs/Component.kt
index a4a1031..1b2a5a8 100644
--- a/scripting/commonMain/kotlin/com/dropbear/ecs/Component.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/ecs/Component.kt
@@ -5,9 +5,9 @@ import com.dropbear.EntityId
 /**
  * Marks a class as a user-defined ECS component for the dropbear engine.
  *
- * Annotated classes must extend [Component]. The magna-carta tool scans for this annotation at
- * build time to generate a [ComponentManager] that registers the component type with the native
- * engine and dispatches lifecycle callbacks from Rust.
+ * Annotated classes must extend [NativeComponent]. The `magna-carta` tool scans for this annotation
+ * at build time to generate a [ComponentManager] that registers each component type with the
+ * native engine's ComponentRegistry via [registerKotlinComponentType].
  *
  * Retention is [AnnotationRetention.RUNTIME] so JNI reflection can discover types at startup
  * without relying on build-time scanning alone.
@@ -15,35 +15,76 @@ import com.dropbear.EntityId
 @Target(AnnotationTarget.CLASS)
 @Retention(AnnotationRetention.RUNTIME)
 annotation class EcsComponent
+
+internal expect fun registerKotlinComponentType(
+    fqcn: String,
+    typeName: String?,
+    category: String?,
+    description: String?,
+)
+
 /**
- * A custom property that *can* be attached to an entity in the dropbear ECS system.
+ * The base class of any component.
  *
- * # Note
- * A [ComponentType] `companion object` is required to be implemented to be queryable. Take a look at
- * the documentation for the object.
+ * Extend [NativeComponent] or [ExternalComponent], not this class directly.
+ */
+sealed class Component
+
+/**
+ * States a Kotlin-defined ECS component.
  *
+ * Annotate subclasses with [@EcsComponent][EcsComponent] so that `magna-carta` can discover them
+ * at build time and emit a `ComponentManager` that calls [register] for every discovered type.
  *
+ * @param fullyQualifiedTypeName The fully qualified class name used as the stable key inside the
+ *   Rust ComponentRegistry (e.g. `"com.example.PlayerHealth"`). Prefer using the literal string
+ *   directly so the value survives code shrinking / obfuscation.
+ * @param typeName Human-readable short name shown in the editor (e.g. `"PlayerHealth"`).
+ * @param category Optional grouping shown in the "Add Component" picker (e.g. `"Gameplay"`).
+ * @param description Optional one-line description shown as a tooltip in the editor.
  */
-abstract class Component(
-    val fullyQualifiedTypeName: String?,
-    val typeName: String?,
-    val category: String?,
-    val description: String?,
-) {
+abstract class NativeComponent(
+    val fullyQualifiedTypeName: String,
+    val typeName: String,
+    val category: String? = null,
+    val description: String? = null,
+): Component() {
     /**
-     * Ran on a component being attached to an entity.
+     * Registers this component type with the engine's ComponentRegistry.
+     *
+     * This is called once per type at startup
      */
-    abstract fun onAttach()
+    fun register() {
+        registerKotlinComponentType(fullyQualifiedTypeName, typeName, category, description)
+    }
 
     /**
-     * Ran on a component being updated.
+     * Renders the inspector UI for this component in the editor.
+     *
+     * Called by the editor for every entity that has this component type attached.
      */
-    abstract fun update()
+    abstract fun inspect()
 
     /**
-     * Ran on a component being detached from an entity.
+     * Updates the component for all entities that contain this component.
      */
-    abstract fun onDetach()
+    abstract fun updateComponent()
+}
+
+/**
+ * Proxy for an ECS component that is defined outside Kotlin, typically in a Rust crate or
+ * another native shared library, and registered with the engine's ComponentRegistry under a
+ * known [fullyQualifiedTypeName].
+ *
+ * Use this when you need to query whether an entity carries a non-Kotlin component, without
+ * owning or duplicating its data on the Kotlin side.
+ *
+ * @param fullyQualifiedTypeName The fully qualified type name that the external component was
+ *   registered under in the ComponentRegistry (e.g. `"eucalyptus_core::components::RigidBody"`).
+ */
+abstract class ExternalComponent(
+    val fullyQualifiedTypeName: String,
+): Component() {
 }
 
 /**
@@ -62,7 +103,7 @@ abstract class Component(
  */
 interface ComponentType<T : Component> {
     /**
-     * Uses the FFI to check if the entity is a components.
+     * Uses the FFI to check if the entity is a component.
      *
      * Since most components are technically just classes with a ctor containing the parentEntity's id,
      * and getters and setters, it just needs to query the world.
diff --git a/scripting/commonMain/kotlin/com/dropbear/lighting/Light.kt b/scripting/commonMain/kotlin/com/dropbear/lighting/Light.kt
index 6371f79..da8626c 100644
--- a/scripting/commonMain/kotlin/com/dropbear/lighting/Light.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/lighting/Light.kt
@@ -1,8 +1,8 @@
 package com.dropbear.lighting
 
 import com.dropbear.EntityId
-import com.dropbear.ecs.Component
 import com.dropbear.ecs.ComponentType
+import com.dropbear.ecs.ExternalComponent
 import com.dropbear.math.Vector3d
 import com.dropbear.utils.Colour
 import com.dropbear.utils.Range
@@ -17,7 +17,7 @@ import com.dropbear.utils.Range
  */
 class Light(
     val entity: EntityId
-): Component(entity, "Light") {
+): ExternalComponent("dropbear_engine::lighting::Light") {
     /**
      * The position of the light in 3D space.
      *
diff --git a/scripting/commonMain/kotlin/com/dropbear/physics/ColliderGroup.kt b/scripting/commonMain/kotlin/com/dropbear/physics/ColliderGroup.kt
index 1e047cc..cb22619 100644
--- a/scripting/commonMain/kotlin/com/dropbear/physics/ColliderGroup.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/physics/ColliderGroup.kt
@@ -1,8 +1,8 @@
 package com.dropbear.physics
 
-import com.dropbear.ecs.Component
 import com.dropbear.EntityId
 import com.dropbear.ecs.ComponentType
+import com.dropbear.ecs.ExternalComponent
 
 /**
  * A component that can added to an entity that defines all the colliders.
@@ -10,8 +10,8 @@ import com.dropbear.ecs.ComponentType
  * This entity requires you to have the `ColliderGroup` component attached to the entity.
  */
 class ColliderGroup(
-    internal val entity: EntityId,
-) : Component(entity, "ColliderGroup") {
+    val entity: EntityId,
+) : ExternalComponent("eucalyptus_core::physics::ColliderGroup") {
 
     /**
      * Fetches all colliders in the group.
diff --git a/scripting/commonMain/kotlin/com/dropbear/physics/KinematicCharacterController.kt b/scripting/commonMain/kotlin/com/dropbear/physics/KinematicCharacterController.kt
index 612f408..bf52a09 100644
--- a/scripting/commonMain/kotlin/com/dropbear/physics/KinematicCharacterController.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/physics/KinematicCharacterController.kt
@@ -1,8 +1,8 @@
 package com.dropbear.physics
 
 import com.dropbear.EntityId
-import com.dropbear.ecs.Component
 import com.dropbear.ecs.ComponentType
+import com.dropbear.ecs.ExternalComponent
 import com.dropbear.math.Quaterniond
 import com.dropbear.math.Vector3d
 import com.dropbear.math.degreesToRadians
@@ -10,7 +10,7 @@ import kotlin.math.cos
 
 class KinematicCharacterController(
     val entity: EntityId,
-) : Component(entity, "KCC") {
+) : ExternalComponent("eucalyptus_core::physics::KCC") {
     val movementResult: CharacterMovementResult?
         get() = getMovementResult()
 
diff --git a/scripting/commonMain/kotlin/com/dropbear/physics/RigidBody.kt b/scripting/commonMain/kotlin/com/dropbear/physics/RigidBody.kt
index 6366c8b..2dfd399 100644
--- a/scripting/commonMain/kotlin/com/dropbear/physics/RigidBody.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/physics/RigidBody.kt
@@ -1,8 +1,8 @@
 package com.dropbear.physics
 
-import com.dropbear.ecs.Component
 import com.dropbear.EntityId
 import com.dropbear.ecs.ComponentType
+import com.dropbear.ecs.ExternalComponent
 import com.dropbear.math.Vector3d
 
 /**
@@ -13,8 +13,8 @@ import com.dropbear.math.Vector3d
  */
 class RigidBody(
     internal val index: Index,
-    internal val entity: EntityId,
-) : Component(entity, "RigidBody") {
+    val entity: EntityId,
+) : ExternalComponent("eucalyptus_core::physics::RigidBody") {
     /**
      * The mode of the rigidbody, as determined through [RigidBodyMode].
      */
diff --git a/scripting/commonMain/kotlin/com/dropbear/rendering/DebugDraw.kt b/scripting/commonMain/kotlin/com/dropbear/rendering/DebugDraw.kt
index 4854e9d..c384a15 100644
--- a/scripting/commonMain/kotlin/com/dropbear/rendering/DebugDraw.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/rendering/DebugDraw.kt
@@ -66,6 +66,13 @@ object DebugDraw {
         drawCapsuleNative(a, b, radius.toFloat(), colour)
 
     /**
+     * Draws a wireframe cylinder centered at [center], aligned to [axis].
+     * [halfHeight] is the half-height along the axis.
+     */
+    fun drawCylinder(center: Vector3d, halfHeight: Double, radius: Double, axis: Vector3d = Vector3d.up(), colour: Colour = Colour.WHITE) =
+        drawCylinderNative(center, halfHeight.toFloat(), radius.toFloat(), axis, colour)
+
+    /**
      * Draws a wireframe cone from [apex] extending in [dir].
      * [angle] is the half-angle in radians. [length] controls how far the cone extends.
      */
@@ -83,4 +90,5 @@ internal expect fun DebugDraw.drawGlobeNative(center: Vector3d, radius: Float, l
 internal expect fun DebugDraw.drawAabbNative(min: Vector3d, max: Vector3d, colour: Colour)
 internal expect fun DebugDraw.drawObbNative(center: Vector3d, halfExtents: Vector3d, rotation: Quaterniond, colour: Colour)
 internal expect fun DebugDraw.drawCapsuleNative(a: Vector3d, b: Vector3d, radius: Float, colour: Colour)
+internal expect fun DebugDraw.drawCylinderNative(center: Vector3d, halfHeight: Float, radius: Float, axis: Vector3d, colour: Colour)
 internal expect fun DebugDraw.drawConeNative(apex: Vector3d, dir: Vector3d, angle: Float, length: Float, colour: Colour)
diff --git a/scripting/commonMain/kotlin/com/dropbear/ui/Inspect.kt b/scripting/commonMain/kotlin/com/dropbear/ui/Inspect.kt
new file mode 100644
index 0000000..b8608b0
--- /dev/null
+++ b/scripting/commonMain/kotlin/com/dropbear/ui/Inspect.kt
@@ -0,0 +1,11 @@
+package com.dropbear.ui
+
+/**
+ * Marks a property of a [NativeComponent][com.dropbear.ecs.NativeComponent] subclass for
+ * display in the editor's inspector panel.
+ *
+ * @param widgetType The widget to render for this property in the inspector.
+ */
+@Target(AnnotationTarget.PROPERTY, AnnotationTarget.FIELD)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class Inspect(val widgetType: WidgetType)
diff --git a/scripting/commonMain/kotlin/com/dropbear/ui/WidgetType.kt b/scripting/commonMain/kotlin/com/dropbear/ui/WidgetType.kt
new file mode 100644
index 0000000..6024e04
--- /dev/null
+++ b/scripting/commonMain/kotlin/com/dropbear/ui/WidgetType.kt
@@ -0,0 +1,19 @@
+package com.dropbear.ui
+
+enum class WidgetType {
+    Label,
+    TextEdit,
+    Button,
+    Checkbox,
+    RadioButton,
+    SelectableLabel,
+    ComboBox,
+    Slider,
+    DragValue,
+    ColourButton,
+    Image,
+    CollapsingHeader, // im not sure about this
+    Panel, // idk what this does
+    ProgressIndicator,
+    Other,
+}
\ No newline at end of file
diff --git a/scripting/jvmMain/java/com/dropbear/components/camera/OnRailsNative.java b/scripting/jvmMain/java/com/dropbear/components/camera/OnRailsNative.java
new file mode 100644
index 0000000..99c2430
--- /dev/null
+++ b/scripting/jvmMain/java/com/dropbear/components/camera/OnRailsNative.java
@@ -0,0 +1,42 @@
+package com.dropbear.components.camera;
+
+import com.dropbear.EucalyptusCoreLoader;
+import com.dropbear.math.Vector3d;
+
+public class OnRailsNative {
+    static {
+        new EucalyptusCoreLoader().ensureLoaded();
+    }
+
+    public static native boolean existsForEntity(long worldPtr, long entityId);
+
+    public static native boolean getEnabled(long worldPtr, long entityId);
+    public static native void setEnabled(long worldPtr, long entityId, boolean enabled);
+
+    public static native float getProgress(long worldPtr, long entityId);
+    public static native void setProgress(long worldPtr, long entityId, float progress);
+
+    public static native int getPathLen(long worldPtr, long entityId);
+    public static native Vector3d getPathPoint(long worldPtr, long entityId, int index);
+    public static native void clearPath(long worldPtr, long entityId);
+    public static native void pushPathPoint(long worldPtr, long entityId, Vector3d point);
+
+    // 0=Automatic, 1=FollowEntity, 2=AxisDriven, 3=Manual
+    public static native int getDriveType(long worldPtr, long entityId);
+
+    public static native float getDriveAutomaticSpeed(long worldPtr, long entityId);
+    public static native boolean getDriveAutomaticLooping(long worldPtr, long entityId);
+
+    public static native long getDriveFollowEntityTarget(long worldPtr, long entityId);
+    public static native boolean getDriveFollowEntityMonotonic(long worldPtr, long entityId);
+
+    public static native long getDriveAxisDrivenTarget(long worldPtr, long entityId);
+    public static native Vector3d getDriveAxisDrivenAxis(long worldPtr, long entityId);
+    public static native float getDriveAxisDrivenRangeMin(long worldPtr, long entityId);
+    public static native float getDriveAxisDrivenRangeMax(long worldPtr, long entityId);
+
+    public static native void setDriveAutomatic(long worldPtr, long entityId, float speed, boolean looping);
+    public static native void setDriveFollowEntity(long worldPtr, long entityId, long target, boolean monotonic);
+    public static native void setDriveAxisDriven(long worldPtr, long entityId, long target, Vector3d axis, float rangeMin, float rangeMax);
+    public static native void setDriveManual(long worldPtr, long entityId);
+}
diff --git a/scripting/jvmMain/java/com/dropbear/rendering/DebugDrawNative.java b/scripting/jvmMain/java/com/dropbear/rendering/DebugDrawNative.java
index f50afad..8fafff1 100644
--- a/scripting/jvmMain/java/com/dropbear/rendering/DebugDrawNative.java
+++ b/scripting/jvmMain/java/com/dropbear/rendering/DebugDrawNative.java
@@ -20,5 +20,6 @@ public class DebugDrawNative {
     public static native void drawAabb(long graphicsContextPtr, Vector3d min, Vector3d max, Colour colour);
     public static native void drawObb(long graphicsContextPtr, Vector3d center, Vector3d halfExtents, Quaterniond rotation, Colour colour);
     public static native void drawCapsule(long graphicsContextPtr, Vector3d a, Vector3d b, float radius, Colour colour);
+    public static native void drawCylinder(long graphicsContextPtr, Vector3d center, float halfHeight, float radius, Vector3d axis, Colour colour);
     public static native void drawCone(long graphicsContextPtr, Vector3d apex, Vector3d dir, float angle, float length, Colour colour);
 }
diff --git a/scripting/jvmMain/kotlin/com/dropbear/ecs/Component.jvm.kt b/scripting/jvmMain/kotlin/com/dropbear/ecs/Component.jvm.kt
index d8333aa..28deed7 100644
--- a/scripting/jvmMain/kotlin/com/dropbear/ecs/Component.jvm.kt
+++ b/scripting/jvmMain/kotlin/com/dropbear/ecs/Component.jvm.kt
@@ -4,9 +4,6 @@ import com.dropbear.DropbearEngine
 import com.dropbear.EntityId
 import com.dropbear.components.ComponentNative
 
-internal actual fun hasKotlinComponent(entityId: EntityId, fqcn: String): Boolean =
-    ComponentNative.hasKotlinComponent(DropbearEngine.native.worldHandle, entityId.raw, fqcn)
-
 internal actual fun registerKotlinComponentType(
     fqcn: String,
     typeName: String?,
diff --git a/scripting/jvmMain/kotlin/com/dropbear/rendering/DebugDraw.jvm.kt b/scripting/jvmMain/kotlin/com/dropbear/rendering/DebugDraw.jvm.kt
index b6a5527..a962c2b 100644
--- a/scripting/jvmMain/kotlin/com/dropbear/rendering/DebugDraw.jvm.kt
+++ b/scripting/jvmMain/kotlin/com/dropbear/rendering/DebugDraw.jvm.kt
@@ -37,5 +37,8 @@ internal actual fun DebugDraw.drawObbNative(center: Vector3d, halfExtents: Vecto
 internal actual fun DebugDraw.drawCapsuleNative(a: Vector3d, b: Vector3d, radius: Float, colour: Colour) =
     DebugDrawNative.drawCapsule(g, a, b, radius, colour)
 
+internal actual fun DebugDraw.drawCylinderNative(center: Vector3d, halfHeight: Float, radius: Float, axis: Vector3d, colour: Colour) =
+    DebugDrawNative.drawCylinder(g, center, halfHeight, radius, axis, colour)
+
 internal actual fun DebugDraw.drawConeNative(apex: Vector3d, dir: Vector3d, angle: Float, length: Float, colour: Colour) =
     DebugDrawNative.drawCone(g, apex, dir, angle, length, colour)
diff --git a/scripting/jvmMain/kotlin/com/dropbear/scene/SceneManager.jvm.kt b/scripting/jvmMain/kotlin/com/dropbear/scene/SceneManager.jvm.kt
index 322d001..8116931 100644
--- a/scripting/jvmMain/kotlin/com/dropbear/scene/SceneManager.jvm.kt
+++ b/scripting/jvmMain/kotlin/com/dropbear/scene/SceneManager.jvm.kt
@@ -29,4 +29,8 @@ internal actual fun SceneManager.switchToSceneImmediateNative(sceneName: String)
         DropbearEngine.native.commandBufferHandle,
         sceneName
     )
+}
+
+internal actual fun SceneManager.getSceneMetadataNative(sceneName: String): SceneMetadata? {
+    TODO("Not yet implemented")
 }
\ No newline at end of file
diff --git a/scripting/jvmMain/kotlin/com/dropbear/scene/SceneMetadata.jvm.kt b/scripting/jvmMain/kotlin/com/dropbear/scene/SceneMetadata.jvm.kt
new file mode 100644
index 0000000..32bcfaf
--- /dev/null
+++ b/scripting/jvmMain/kotlin/com/dropbear/scene/SceneMetadata.jvm.kt
@@ -0,0 +1,5 @@
+package com.dropbear.scene
+
+import com.dropbear.EntityRef
+
+actual fun SceneMetadata.getEntities(): List<EntityRef> = emptyList()
diff --git a/scripting/nativeMain/kotlin/com/dropbear/DropbearEngine.native.kt b/scripting/nativeMain/kotlin/com/dropbear/DropbearEngine.native.kt
index 71047e1..3ccf34b 100644
--- a/scripting/nativeMain/kotlin/com/dropbear/DropbearEngine.native.kt
+++ b/scripting/nativeMain/kotlin/com/dropbear/DropbearEngine.native.kt
@@ -20,12 +20,12 @@ internal actual fun getAsset(eucaURI: String): Long? = memScoped {
     val outPresent = alloc<BooleanVar>()
 
     val kindVar = alloc<UIntVar>()
-    // Try Texture
+    // try tex
     kindVar.value = AssetKind_Texture
     var rc = dropbear_engine_get_asset(assets, eucaURI, kindVar.ptr, outId.ptr, outPresent.ptr)
     if (rc == 0 && outPresent.value) return@memScoped outId.value.toLong()
 
-    // Try Model
+    // try model
     kindVar.value = AssetKind_Model
     rc = dropbear_engine_get_asset(assets, eucaURI, kindVar.ptr, outId.ptr, outPresent.ptr)
     if (rc == 0 && outPresent.value) outId.value.toLong() else null
diff --git a/scripting/nativeMain/kotlin/com/dropbear/components/EntityTransform.native.kt b/scripting/nativeMain/kotlin/com/dropbear/components/EntityTransform.native.kt
index 10a3f45..e4970c2 100644
--- a/scripting/nativeMain/kotlin/com/dropbear/components/EntityTransform.native.kt
+++ b/scripting/nativeMain/kotlin/com/dropbear/components/EntityTransform.native.kt
@@ -45,6 +45,6 @@ internal actual fun EntityTransform.setWorldTransform(entityId: EntityId, transf
 internal actual fun EntityTransform.propagateTransform(entityId: EntityId): Transform? = memScoped {
     val world = DropbearEngine.native.worldHandle ?: return@memScoped null
     val out = alloc<NTransform>()
-    val rc = dropbear_transform_propogate_transform(world, entityId.raw.toULong(), out.ptr)
+    val rc = dropbear_transform_propagate_transform(world, entityId.raw.toULong(), out.ptr)
     if (rc != 0) null else readTransform(out)
 }
\ No newline at end of file
diff --git a/scripting/nativeMain/kotlin/com/dropbear/ecs/Component.native.kt b/scripting/nativeMain/kotlin/com/dropbear/ecs/Component.native.kt
index ce42197..df305ba 100644
--- a/scripting/nativeMain/kotlin/com/dropbear/ecs/Component.native.kt
+++ b/scripting/nativeMain/kotlin/com/dropbear/ecs/Component.native.kt
@@ -1,22 +1,11 @@
-@file:OptIn(ExperimentalForeignApi::class)
-
 package com.dropbear.ecs
 
-import com.dropbear.DropbearEngine
 import com.dropbear.EntityId
-import com.dropbear.ffi.generated.*
-import kotlinx.cinterop.*
 
-internal actual fun hasKotlinComponent(entityId: EntityId, fqcn: String): Boolean = memScoped {
-    val world = DropbearEngine.native.worldHandle ?: return@memScoped false
-    dropbear_kotlin_component_exists(world, entityId.raw.toULong(), fqcn)
-}
 
 internal actual fun registerKotlinComponentType(
     fqcn: String,
     typeName: String?,
     category: String?,
     description: String?,
-) {
-    dropbear_register_kotlin_component(fqcn, typeName, category, description)
-}
+) = Unit
\ No newline at end of file
diff --git a/scripting/nativeMain/kotlin/com/dropbear/rendering/DebugDraw.native.kt b/scripting/nativeMain/kotlin/com/dropbear/rendering/DebugDraw.native.kt
index a30b4d5..32971b1 100644
--- a/scripting/nativeMain/kotlin/com/dropbear/rendering/DebugDraw.native.kt
+++ b/scripting/nativeMain/kotlin/com/dropbear/rendering/DebugDraw.native.kt
@@ -59,86 +59,12 @@ internal actual fun DebugDraw.drawCapsuleNative(a: Vector3d, b: Vector3d, radius
     dropbear_debug_draw_capsule(g, allocVec3(a).ptr, allocVec3(b).ptr, radius, allocColour(colour).ptr)
 }
 
-internal actual fun DebugDraw.drawConeNative(apex: Vector3d, dir: Vector3d, angle: Float, length: Float, colour: Colour) = memScoped {
-    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
-    dropbear_debug_draw_cone(g, allocVec3(apex).ptr, allocVec3(dir).ptr, angle, length, allocColour(colour).ptr)
-}
-
-
-internal actual fun DebugDraw.drawLineNative(start: Vector3d, end: Vector3d, colour: Colour) = memScoped {
-    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
-    val s = allocVec3(start); val e = allocVec3(end)
-    val c = nColour(colour).toCValue(this)
-    dropbear_debug_draw_line(g, s.ptr, e.ptr, c)
-}
-
-internal actual fun DebugDraw.drawRayNative(origin: Vector3d, dir: Vector3d, colour: Colour) = memScoped {
-    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
-    val o = allocVec3(origin); val d = allocVec3(dir)
-    val c = nColour(colour).toCValue(this)
-    dropbear_debug_draw_ray(g, o.ptr, d.ptr, c)
-}
-
-internal actual fun DebugDraw.drawArrowNative(start: Vector3d, end: Vector3d, colour: Colour) = memScoped {
-    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
-    val s = allocVec3(start); val e = allocVec3(end)
-    val c = nColour(colour).toCValue(this)
-    dropbear_debug_draw_arrow(g, s.ptr, e.ptr, c)
-}
-
-internal actual fun DebugDraw.drawPointNative(pos: Vector3d, size: Float, colour: Colour) = memScoped {
-    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
-    val p = allocVec3(pos)
-    val c = nColour(colour).toCValue(this)
-    dropbear_debug_draw_point(g, p.ptr, size, c)
-}
-
-internal actual fun DebugDraw.drawCircleNative(center: Vector3d, radius: Float, normal: Vector3d, colour: Colour) = memScoped {
-    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
-    val ct = allocVec3(center); val n = allocVec3(normal)
-    val c = nColour(colour).toCValue(this)
-    dropbear_debug_draw_circle(g, ct.ptr, radius, n.ptr, c)
-}
-
-internal actual fun DebugDraw.drawSphereNative(center: Vector3d, radius: Float, colour: Colour) = memScoped {
-    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
-    val ct = allocVec3(center)
-    val c = nColour(colour).toCValue(this)
-    dropbear_debug_draw_sphere(g, ct.ptr, radius, c)
-}
-
-internal actual fun DebugDraw.drawGlobeNative(center: Vector3d, radius: Float, latLines: Int, lonLines: Int, colour: Colour) = memScoped {
-    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
-    val ct = allocVec3(center)
-    val c = nColour(colour).toCValue(this)
-    dropbear_debug_draw_globe(g, ct.ptr, radius, latLines.toUInt(), lonLines.toUInt(), c)
-}
-
-internal actual fun DebugDraw.drawAabbNative(min: Vector3d, max: Vector3d, colour: Colour) = memScoped {
-    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
-    val mn = allocVec3(min); val mx = allocVec3(max)
-    val c = nColour(colour).toCValue(this)
-    dropbear_debug_draw_aabb(g, mn.ptr, mx.ptr, c)
-}
-
-internal actual fun DebugDraw.drawObbNative(center: Vector3d, halfExtents: Vector3d, rotation: Quaterniond, colour: Colour) = memScoped {
+internal actual fun DebugDraw.drawCylinderNative(center: Vector3d, halfHeight: Float, radius: Float, axis: Vector3d, colour: Colour) = memScoped {
     val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
-    val ct = allocVec3(center); val he = allocVec3(halfExtents)
-    val rot = allocQuat(rotation)
-    val c = nColour(colour).toCValue(this)
-    dropbear_debug_draw_obb(g, ct.ptr, he.ptr, rot.ptr, c)
-}
-
-internal actual fun DebugDraw.drawCapsuleNative(a: Vector3d, b: Vector3d, radius: Float, colour: Colour) = memScoped {
-    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
-    val va = allocVec3(a); val vb = allocVec3(b)
-    val c = nColour(colour).toCValue(this)
-    dropbear_debug_draw_capsule(g, va.ptr, vb.ptr, radius, c)
+    dropbear_debug_draw_cylinder(g, allocVec3(center).ptr, halfHeight, radius, allocVec3(axis).ptr, allocColour(colour).ptr)
 }
 
 internal actual fun DebugDraw.drawConeNative(apex: Vector3d, dir: Vector3d, angle: Float, length: Float, colour: Colour) = memScoped {
     val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
-    val a = allocVec3(apex); val d = allocVec3(dir)
-    val c = nColour(colour).toCValue(this)
-    dropbear_debug_draw_cone(g, a.ptr, d.ptr, angle, length, c)
+    dropbear_debug_draw_cone(g, allocVec3(apex).ptr, allocVec3(dir).ptr, angle, length, allocColour(colour).ptr)
 }
diff --git a/scripting/nativeMain/kotlin/com/dropbear/scene/SceneManager.native.kt b/scripting/nativeMain/kotlin/com/dropbear/scene/SceneManager.native.kt
index d1d353f..476fb09 100644
--- a/scripting/nativeMain/kotlin/com/dropbear/scene/SceneManager.native.kt
+++ b/scripting/nativeMain/kotlin/com/dropbear/scene/SceneManager.native.kt
@@ -26,4 +26,10 @@ internal actual fun SceneManager.loadSceneAsyncNative(sceneName: String, loading
 internal actual fun SceneManager.switchToSceneImmediateNative(sceneName: String) {
     val cmd = DropbearEngine.native.commandBufferHandle ?: return
     memScoped { dropbear_scripting_switch_to_scene_immediate(cmd, sceneName) }
-}
\ No newline at end of file
+}
+
+internal actual fun SceneManager.getSceneMetadataNative(sceneName: String): SceneMetadata? {
+    TODO("Not yet implemented")
+}
+
+actual fun SceneMetadata.getEntities(): List<com.dropbear.EntityRef> = emptyList()
\ No newline at end of file
diff --git a/src/jvmMain/kotlin/com/dropbear/components/camera/OnRails.jvm.kt b/src/jvmMain/kotlin/com/dropbear/components/camera/OnRails.jvm.kt
new file mode 100644
index 0000000..267ae37
--- /dev/null
+++ b/src/jvmMain/kotlin/com/dropbear/components/camera/OnRails.jvm.kt
@@ -0,0 +1,77 @@
+package com.dropbear.components.camera
+
+import com.dropbear.DropbearEngine
+import com.dropbear.EntityId
+import com.dropbear.math.Vector3d
+import com.dropbear.math.Vector3f
+
+internal actual fun onRailsExistsForEntity(entityId: EntityId): Boolean =
+    OnRailsNative.existsForEntity(DropbearEngine.native.worldHandle, entityId.raw)
+
+internal actual fun OnRails.onRailsGetEnabled(): Boolean =
+    OnRailsNative.getEnabled(DropbearEngine.native.worldHandle, entity.raw)
+
+internal actual fun OnRails.onRailsSetEnabled(enabled: Boolean) =
+    OnRailsNative.setEnabled(DropbearEngine.native.worldHandle, entity.raw, enabled)
+
+internal actual fun OnRails.onRailsGetProgress(): Float =
+    OnRailsNative.getProgress(DropbearEngine.native.worldHandle, entity.raw)
+
+internal actual fun OnRails.onRailsSetProgress(progress: Float) =
+    OnRailsNative.setProgress(DropbearEngine.native.worldHandle, entity.raw, progress)
+
+internal actual fun OnRails.onRailsGetPath(): List<Vector3d> {
+    val world = DropbearEngine.native.worldHandle
+    val len = OnRailsNative.getPathLen(world, entity.raw)
+    return (0 until len).map { i -> OnRailsNative.getPathPoint(world, entity.raw, i) }
+}
+
+internal actual fun OnRails.onRailsSetPath(path: List<Vector3d>) {
+    val world = DropbearEngine.native.worldHandle
+    OnRailsNative.clearPath(world, entity.raw)
+    path.forEach { p -> OnRailsNative.pushPathPoint(world, entity.raw, p) }
+}
+
+internal actual fun OnRails.onRailsGetDrive(): RailDrive {
+    val world = DropbearEngine.native.worldHandle
+    val entityRaw = entity.raw
+    return when (OnRailsNative.getDriveType(world, entityRaw)) {
+        0 -> RailDrive.Automatic(
+            speed   = OnRailsNative.getDriveAutomaticSpeed(world, entityRaw),
+            looping = OnRailsNative.getDriveAutomaticLooping(world, entityRaw),
+        )
+        1 -> RailDrive.FollowEntity(
+            target    = EntityId(OnRailsNative.getDriveFollowEntityTarget(world, entityRaw)),
+            monotonic = OnRailsNative.getDriveFollowEntityMonotonic(world, entityRaw),
+        )
+        2 -> {
+            val axisD = OnRailsNative.getDriveAxisDrivenAxis(world, entityRaw)
+            RailDrive.AxisDriven(
+                target   = EntityId(OnRailsNative.getDriveAxisDrivenTarget(world, entityRaw)),
+                axis     = Vector3f(axisD.x.toFloat(), axisD.y.toFloat(), axisD.z.toFloat()),
+                rangeMin = OnRailsNative.getDriveAxisDrivenRangeMin(world, entityRaw),
+                rangeMax = OnRailsNative.getDriveAxisDrivenRangeMax(world, entityRaw),
+            )
+        }
+        else -> RailDrive.Manual
+    }
+}
+
+internal actual fun OnRails.onRailsSetDrive(drive: RailDrive) {
+    val world = DropbearEngine.native.worldHandle
+    val entityRaw = entity.raw
+    when (drive) {
+        is RailDrive.Automatic ->
+            OnRailsNative.setDriveAutomatic(world, entityRaw, drive.speed, drive.looping)
+        is RailDrive.FollowEntity ->
+            OnRailsNative.setDriveFollowEntity(world, entityRaw, drive.target.raw, drive.monotonic)
+        is RailDrive.AxisDriven ->
+            OnRailsNative.setDriveAxisDriven(
+                world, entityRaw, drive.target.raw,
+                Vector3d(drive.axis.x.toDouble(), drive.axis.y.toDouble(), drive.axis.z.toDouble()),
+                drive.rangeMin, drive.rangeMax,
+            )
+        is RailDrive.Manual ->
+            OnRailsNative.setDriveManual(world, entityRaw)
+    }
+}
diff --git a/src/jvmMain/kotlin/com/dropbear/scene/SceneManager.jvm.kt b/src/jvmMain/kotlin/com/dropbear/scene/SceneManager.jvm.kt
new file mode 100644
index 0000000..7ab1b79
--- /dev/null
+++ b/src/jvmMain/kotlin/com/dropbear/scene/SceneManager.jvm.kt
@@ -0,0 +1,2 @@
+package com.dropbear.scene
+
diff --git a/src/jvmMain/kotlin/com/dropbear/scene/SceneSettings.jvm.kt b/src/jvmMain/kotlin/com/dropbear/scene/SceneSettings.jvm.kt
new file mode 100644
index 0000000..5d94704
--- /dev/null
+++ b/src/jvmMain/kotlin/com/dropbear/scene/SceneSettings.jvm.kt
@@ -0,0 +1,33 @@
+package com.dropbear.scene
+
+actual fun com.dropbear.scene.SceneSettings.getPreload(): Boolean {
+    TODO("Not yet implemented")
+}
+
+actual fun SceneSettings.getHitboxState(): Boolean {
+    TODO("Not yet implemented")
+}
+
+actual fun SceneSettings.setHitboxState(value: Boolean) {
+}
+
+actual fun SceneSettings.getOverlayHUDState(): Boolean {
+    TODO("Not yet implemented")
+}
+
+actual fun SceneSettings.setOverlayHUDState(value: Boolean) {
+}
+
+actual fun SceneSettings.getOverlayBillboardState(): Boolean {
+    TODO("Not yet implemented")
+}
+
+actual fun SceneSettings.setOverlayBillboardState(value: Boolean) {
+}
+
+actual fun SceneSettings.getAmbientStrength(): Double {
+    TODO("Not yet implemented")
+}
+
+actual fun SceneSettings.setAmbientStrength(value: Double) {
+}
\ No newline at end of file
diff --git a/src/nativeMain/kotlin/com/dropbear/components/camera/OnRails.native.kt b/src/nativeMain/kotlin/com/dropbear/components/camera/OnRails.native.kt
new file mode 100644
index 0000000..f19836a
--- /dev/null
+++ b/src/nativeMain/kotlin/com/dropbear/components/camera/OnRails.native.kt
@@ -0,0 +1,123 @@
+@file:OptIn(ExperimentalForeignApi::class)
+
+package com.dropbear.components.camera
+
+import com.dropbear.DropbearEngine
+import com.dropbear.EntityId
+import com.dropbear.ffi.generated.*
+import com.dropbear.math.Vector3d
+import com.dropbear.math.Vector3f
+import kotlinx.cinterop.*
+
+internal actual fun onRailsExistsForEntity(entityId: EntityId): Boolean = memScoped {
+    val world = DropbearEngine.native.worldHandle ?: return@memScoped false
+    val out = alloc<BooleanVar>()
+    dropbear_transform_on_rails_exists_for_entity(world, entityId.raw.toULong(), out.ptr)
+    out.value
+}
+
+internal actual fun OnRails.onRailsGetEnabled(): Boolean = memScoped {
+    val world = DropbearEngine.native.worldHandle ?: return@memScoped false
+    val out = alloc<BooleanVar>()
+    dropbear_transform_on_rails_get_enabled(world, entity.raw.toULong(), out.ptr)
+    out.value
+}
+
+internal actual fun OnRails.onRailsSetEnabled(enabled: Boolean) = memScoped {
+    val world = DropbearEngine.native.worldHandle ?: return@memScoped
+    dropbear_transform_on_rails_set_enabled(world, entity.raw.toULong(), enabled)
+}
+
+internal actual fun OnRails.onRailsGetProgress(): Float = memScoped {
+    val world = DropbearEngine.native.worldHandle ?: return@memScoped 0f
+    val out = alloc<FloatVar>()
+    dropbear_transform_on_rails_get_progress(world, entity.raw.toULong(), out.ptr)
+    out.value
+}
+
+internal actual fun OnRails.onRailsSetProgress(progress: Float) = memScoped {
+    val world = DropbearEngine.native.worldHandle ?: return@memScoped
+    dropbear_transform_on_rails_set_progress(world, entity.raw.toULong(), progress)
+}
+
+internal actual fun OnRails.onRailsGetPath(): List<Vector3d> = memScoped {
+    val world = DropbearEngine.native.worldHandle ?: return@memScoped emptyList()
+    val lenOut = alloc<IntVar>()
+    val rc = dropbear_transform_on_rails_get_path_len(world, entity.raw.toULong(), lenOut.ptr)
+    if (rc != 0) return@memScoped emptyList()
+    val len = lenOut.value
+    (0 until len).mapNotNull { i ->
+        val out = alloc<NVector3>()
+        val rc2 = dropbear_transform_on_rails_get_path_point(world, entity.raw.toULong(), i, out.ptr)
+        if (rc2 != 0) null else Vector3d(out.x, out.y, out.z)
+    }
+}
+
+internal actual fun OnRails.onRailsSetPath(path: List<Vector3d>) = memScoped {
+    val world = DropbearEngine.native.worldHandle ?: return@memScoped
+    dropbear_transform_on_rails_clear_path(world, entity.raw.toULong())
+    for (point in path) {
+        val v = alloc<NVector3>()
+        v.x = point.x; v.y = point.y; v.z = point.z
+        dropbear_transform_on_rails_push_path_point(world, entity.raw.toULong(), v.ptr)
+    }
+}
+
+internal actual fun OnRails.onRailsGetDrive(): RailDrive = memScoped {
+    val world = DropbearEngine.native.worldHandle ?: return@memScoped RailDrive.Manual
+    val typeOut = alloc<IntVar>()
+    val rc = dropbear_transform_on_rails_get_drive_type(world, entity.raw.toULong(), typeOut.ptr)
+    if (rc != 0) return@memScoped RailDrive.Manual
+
+    when (typeOut.value) {
+        0 -> {
+            val speed = alloc<FloatVar>()
+            val looping = alloc<BooleanVar>()
+            dropbear_transform_on_rails_get_drive_automatic_speed(world, entity.raw.toULong(), speed.ptr)
+            dropbear_transform_on_rails_get_drive_automatic_looping(world, entity.raw.toULong(), looping.ptr)
+            RailDrive.Automatic(speed.value, looping.value)
+        }
+        1 -> {
+            val target = alloc<ULongVar>()
+            val monotonic = alloc<BooleanVar>()
+            dropbear_transform_on_rails_get_drive_follow_entity_target(world, entity.raw.toULong(), target.ptr)
+            dropbear_transform_on_rails_get_drive_follow_entity_monotonic(world, entity.raw.toULong(), monotonic.ptr)
+            RailDrive.FollowEntity(EntityId(target.value.toLong()), monotonic.value)
+        }
+        2 -> {
+            val target = alloc<ULongVar>()
+            val axis = alloc<NVector3>()
+            val rangeMin = alloc<FloatVar>()
+            val rangeMax = alloc<FloatVar>()
+            dropbear_transform_on_rails_get_drive_axis_driven_target(world, entity.raw.toULong(), target.ptr)
+            dropbear_transform_on_rails_get_drive_axis_driven_axis(world, entity.raw.toULong(), axis.ptr)
+            dropbear_transform_on_rails_get_drive_axis_driven_range_min(world, entity.raw.toULong(), rangeMin.ptr)
+            dropbear_transform_on_rails_get_drive_axis_driven_range_max(world, entity.raw.toULong(), rangeMax.ptr)
+            RailDrive.AxisDriven(
+                EntityId(target.value.toLong()),
+                Vector3f(axis.x.toFloat(), axis.y.toFloat(), axis.z.toFloat()),
+                rangeMin.value,
+                rangeMax.value,
+            )
+        }
+        else -> RailDrive.Manual
+    }
+}
+
+internal actual fun OnRails.onRailsSetDrive(drive: RailDrive) = memScoped {
+    val world = DropbearEngine.native.worldHandle ?: return@memScoped
+    val entityBits = entity.raw.toULong()
+    when (drive) {
+        is RailDrive.Automatic ->
+            dropbear_transform_on_rails_set_drive_automatic(world, entityBits, drive.speed, drive.looping)
+        is RailDrive.FollowEntity ->
+            dropbear_transform_on_rails_set_drive_follow_entity(world, entityBits, drive.target.raw.toULong(), drive.monotonic)
+        is RailDrive.AxisDriven -> {
+            val v = alloc<NVector3>()
+            v.x = drive.axis.x.toDouble(); v.y = drive.axis.y.toDouble(); v.z = drive.axis.z.toDouble()
+            dropbear_transform_on_rails_set_drive_axis_driven(world, entityBits, drive.target.raw.toULong(), v.ptr, drive.rangeMin, drive.rangeMax)
+        }
+        is RailDrive.Manual ->
+            dropbear_transform_on_rails_set_drive_manual(world, entityBits)
+    }
+}
diff --git a/src/nativeMain/kotlin/com/dropbear/scene/SceneManager.native.kt b/src/nativeMain/kotlin/com/dropbear/scene/SceneManager.native.kt
new file mode 100644
index 0000000..4eabbce
--- /dev/null
+++ b/src/nativeMain/kotlin/com/dropbear/scene/SceneManager.native.kt
@@ -0,0 +1 @@
+package com.dropbear.scene
\ No newline at end of file
diff --git a/src/nativeMain/kotlin/com/dropbear/scene/SceneSettings.native.kt b/src/nativeMain/kotlin/com/dropbear/scene/SceneSettings.native.kt
new file mode 100644
index 0000000..5d94704
--- /dev/null
+++ b/src/nativeMain/kotlin/com/dropbear/scene/SceneSettings.native.kt
@@ -0,0 +1,33 @@
+package com.dropbear.scene
+
+actual fun com.dropbear.scene.SceneSettings.getPreload(): Boolean {
+    TODO("Not yet implemented")
+}
+
+actual fun SceneSettings.getHitboxState(): Boolean {
+    TODO("Not yet implemented")
+}
+
+actual fun SceneSettings.setHitboxState(value: Boolean) {
+}
+
+actual fun SceneSettings.getOverlayHUDState(): Boolean {
+    TODO("Not yet implemented")
+}
+
+actual fun SceneSettings.setOverlayHUDState(value: Boolean) {
+}
+
+actual fun SceneSettings.getOverlayBillboardState(): Boolean {
+    TODO("Not yet implemented")
+}
+
+actual fun SceneSettings.setOverlayBillboardState(value: Boolean) {
+}
+
+actual fun SceneSettings.getAmbientStrength(): Double {
+    TODO("Not yet implemented")
+}
+
+actual fun SceneSettings.setAmbientStrength(value: Double) {
+}
\ No newline at end of file