kitgit

tirbofish/dropbear · commit

6d99ab132dcaa8b2d89feee0cfcbaeca3b0d2a6b

feature: basic rectangle rendering onto the screen. also start of UI branch. jarvis, run github actions (my build broke, so likely it will break in the action). Unverified

Thribhu K <4tkbytes@pm.me> · 2026-01-20 15:34

view full diff

diff --git a/Cargo.toml b/Cargo.toml
index 6652f99..8e2dd93 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,7 +7,7 @@ package.readme = "README.md"
 
 resolver = "3"
 members = [
-    "crates/*"
+    "crates/*",
 ]
 
 [workspace.dependencies]
diff --git a/crates/eucalyptus-core/Cargo.toml b/crates/eucalyptus-core/Cargo.toml
index 47772d6..b45ebbb 100644
--- a/crates/eucalyptus-core/Cargo.toml
+++ b/crates/eucalyptus-core/Cargo.toml
@@ -7,7 +7,7 @@ repository.workspace = true
 readme = "README.md"
 
 [lib]
-crate-type = ["rlib", "cdylib"]
+crate-type = ["rlib", "dylib"]
 
 [dependencies]
 dropbear-traits = { path = "../dropbear-traits" }
diff --git a/crates/eucalyptus-core/src/lib.rs b/crates/eucalyptus-core/src/lib.rs
index d0cc99e..25f83a6 100644
--- a/crates/eucalyptus-core/src/lib.rs
+++ b/crates/eucalyptus-core/src/lib.rs
@@ -22,6 +22,7 @@ pub mod mesh;
 pub mod entity;
 pub mod engine;
 pub mod transform;
+pub mod ui;
 
 pub use dropbear_macro as macros;
 pub use dropbear_traits as traits;
diff --git a/crates/eucalyptus-core/src/ptr.rs b/crates/eucalyptus-core/src/ptr.rs
index 5c3adab..86bae38 100644
--- a/crates/eucalyptus-core/src/ptr.rs
+++ b/crates/eucalyptus-core/src/ptr.rs
@@ -7,6 +7,7 @@ use hecs::World;
 use parking_lot::Mutex;
 use crate::physics::PhysicsState;
 use crate::scene::loading::SceneLoader;
+use crate::ui::{UiContext};
 
 /// A mutable pointer to a [`World`].
 ///
@@ -41,4 +42,10 @@ pub type SceneLoaderPtr = *const Mutex<SceneLoader>;
 /// A mutable pointer to a [`PhysicsState`].
 ///
 /// Defined in `dropbear_common.h` as `PhysicsEngine`
-pub type PhysicsStatePtr = *mut PhysicsState;
\ No newline at end of file
+pub type PhysicsStatePtr = *mut PhysicsState;
+
+/// A mutable pointer to a [`UiContext`], used for queueing UI components
+/// in the scripting module. 
+/// 
+/// Defined in `dropbear_common.h` as `UiBufferPtr`. 
+pub type UiBufferPtr = *const UiContext;
\ No newline at end of file
diff --git a/crates/eucalyptus-core/src/scripting.rs b/crates/eucalyptus-core/src/scripting.rs
index f2a6965..e8a4d7e 100644
--- a/crates/eucalyptus-core/src/scripting.rs
+++ b/crates/eucalyptus-core/src/scripting.rs
@@ -12,7 +12,7 @@ pub static JVM_ARGS: OnceLock<String> = OnceLock::new();
 pub static AWAIT_JDB: OnceLock<bool> = OnceLock::new();
 
 use std::sync::OnceLock;
-use crate::ptr::{AssetRegistryPtr, CommandBufferPtr, InputStatePtr, PhysicsStatePtr, SceneLoaderPtr, WorldPtr};
+use crate::ptr::{AssetRegistryPtr, CommandBufferPtr, InputStatePtr, PhysicsStatePtr, SceneLoaderPtr, UiBufferPtr, WorldPtr};
 use crate::scripting::jni::JavaContext;
 use crate::scripting::native::NativeLibrary;
 use crate::states::{Script};
@@ -29,6 +29,7 @@ use dropbear_engine::model::MODEL_CACHE;
 use magna_carta::Target;
 use crate::scene::loading::SCENE_LOADER;
 use crate::types::{CollisionEvent, ContactForceEvent};
+use crate::ui::UI_COMMAND_BUFFER;
 
 /// The target of the script. This can be either a JVM or a native library.
 #[derive(Default, Clone, Debug)]
@@ -225,6 +226,8 @@ impl ScriptManager {
         
         let model_cache_ptr = &raw const *MODEL_CACHE;
         ASSET_REGISTRY.add_pointer(Const("model_cache"), model_cache_ptr as usize);
+        
+        let ui_buf = &raw const *UI_COMMAND_BUFFER;
 
         let context = DropbearContext {
             world,
@@ -233,6 +236,7 @@ impl ScriptManager {
             assets,
             scene_loader,
             physics_state,
+            ui_buf,
         };
 
         if world.is_null() { log::error!("World pointer is null"); }
@@ -966,4 +970,5 @@ pub struct DropbearContext {
     pub assets: AssetRegistryPtr,
     pub scene_loader: SceneLoaderPtr,
     pub physics_state: PhysicsStatePtr,
+    pub ui_buf: UiBufferPtr,
 }
\ No newline at end of file
diff --git a/crates/eucalyptus-core/src/scripting/jni.rs b/crates/eucalyptus-core/src/scripting/jni.rs
index ca6ee72..5d97c3b 100644
--- a/crates/eucalyptus-core/src/scripting/jni.rs
+++ b/crates/eucalyptus-core/src/scripting/jni.rs
@@ -315,6 +315,7 @@ impl JavaContext {
             let asset_handle = context.assets as jlong;
             let scene_loader_handle = context.scene_loader as jlong;
             let physics_handle = context.physics_state as jlong;
+            let ui_handle = context.ui_buf as jlong;
 
             let args = [
                 JValue::Long(world_handle),
@@ -322,7 +323,8 @@ impl JavaContext {
                 JValue::Long(graphics_handle),
                 JValue::Long(asset_handle),
                 JValue::Long(scene_loader_handle),
-                JValue::Long(physics_handle)
+                JValue::Long(physics_handle),
+                JValue::Long(ui_handle),
             ];
 
             let mut sig = String::from("(");
diff --git a/crates/eucalyptus-core/src/ui.rs b/crates/eucalyptus-core/src/ui.rs
new file mode 100644
index 0000000..9b01965
--- /dev/null
+++ b/crates/eucalyptus-core/src/ui.rs
@@ -0,0 +1,215 @@
+pub mod rect;
+
+use egui::{Rect, Response, Sense};
+use once_cell::sync::Lazy;
+use parking_lot::Mutex;
+use crate::utils::hashmap::StaleTracker;
+
+pub static UI_COMMAND_BUFFER: Lazy<UiContext> = Lazy::new(|| UiContext::new());
+
+pub enum UiCommand {
+    Rect {
+        id: u64,
+        initial: (f32, f32),
+        size: (f32, f32),
+        corner_radius: f32,
+        stroke: egui::Stroke,
+        fill: egui::Color32,
+        stroke_kind: egui::StrokeKind,
+    },
+    Circle {
+        id: u64,
+        center_x: f64,
+        center_y: f64,
+        radius: f64,
+    },
+}
+
+pub struct UiContext {
+    command_buffer: Mutex<Vec<UiCommand>>,
+    currently_rendering: Mutex<StaleTracker<u64, Response>>,
+}
+
+impl UiContext {
+    pub fn new() -> Self {
+        Self {
+            command_buffer: Mutex::new(Vec::new()),
+            currently_rendering: Mutex::new(StaleTracker::new()),
+        }
+    }
+
+    pub fn push(&self, command: UiCommand) {
+        self.command_buffer.lock().push(command);
+    }
+}
+
+pub fn poll(ui: &mut egui::Ui) -> anyhow::Result<()> {
+    let mut buffer = UI_COMMAND_BUFFER.command_buffer.lock();
+    let mut rendering = UI_COMMAND_BUFFER.currently_rendering.lock();
+    rendering.tick();
+    for cmd in buffer.drain(..) {
+        match cmd {
+            UiCommand::Rect {
+                id,
+                initial,
+                size,
+                corner_radius,
+                stroke,
+                fill,
+                stroke_kind
+            } => {
+                let (resp, painter) = ui.allocate_painter(size.into(), Sense::hover());
+
+                painter.rect(
+                    Rect {
+                        min: initial.into(),
+                        max: [initial.0 + size.0, initial.1 + size.1].into(),
+                    },
+                    corner_radius,
+                    fill,
+                    stroke,
+                    stroke_kind
+                );
+
+                rendering.insert(id, resp);
+            }
+            UiCommand::Circle { .. } => {
+
+            }
+        }
+    }
+
+    // remove anything past 3 gen
+    rendering.remove_stale(3);
+
+    Ok(())
+}
+
+pub mod jni {
+    #![allow(non_snake_case)]
+
+    use jni::JNIEnv;
+    use jni::sys::{jboolean, jlong};
+    use jni::objects::JObject;
+    use crate::{convert_ptr};
+    use crate::scripting::jni::utils::FromJObject;
+    use crate::ui::{UiCommand, UiContext};
+    use crate::ui::rect::Rect;
+
+    #[unsafe(no_mangle)]
+    pub extern "system" fn Java_com_dropbear_ui_UINative_pushRect(
+        mut env: JNIEnv,
+        _class: jni::objects::JClass,
+        ui_buffer_handle: jlong,
+        rect: JObject,
+    ) {
+        let ui = convert_ptr!(ui_buffer_handle => UiContext);
+
+        let rect: Rect = match Rect::from_jobject(&mut env, &rect) {
+            Ok(v) => v,
+            Err(e) => {
+                let _ = env.throw_new("java/lang/RuntimeException", format!("Unable to convert Rectangle->Rect: {:?}", e));
+                return;
+            }
+        };
+
+        ui.push(UiCommand::Rect {
+            id: rect.id,
+            initial: rect.initial_pos,
+            size: rect.size,
+            corner_radius: rect.corner_radius,
+            stroke: rect.stroke,
+            fill: rect.fill,
+            stroke_kind: rect.stroke_kind,
+        });
+    }
+    
+    #[unsafe(no_mangle)]
+    pub extern "system" fn Java_com_dropbear_ui_UINative_pushCircle(
+        mut env: JNIEnv,
+        _class: jni::objects::JClass,
+        ui_buffer_handle: jlong,
+        circle: JObject,
+    ) {
+        let ui = convert_ptr!(ui_buffer_handle => UiContext);
+
+        // Extract Circle fields
+        let id_obj = match env
+            .get_field(&circle, "id", "Lcom/dropbear/utils/ID;")
+            .and_then(|v| v.l())
+        {
+            Ok(obj) => obj,
+            Err(e) => {
+                let _ = env.throw_new("java/lang/RuntimeException", format!("Failed to get id field: {}", e));
+                return;
+            }
+        };
+
+        let id = match env.call_method(&id_obj, "getId", "()J", &[]).and_then(|v| v.j()) {
+            Ok(val) => val as u64,
+            Err(e) => {
+                let _ = env.throw_new("java/lang/RuntimeException", format!("Failed to get id value: {}", e));
+                return;
+            }
+        };
+
+        let center_obj = match env
+            .get_field(&circle, "center", "Lcom/dropbear/math/Vector2d;")
+            .and_then(|v| v.l())
+        {
+            Ok(obj) => obj,
+            Err(e) => {
+                let _ = env.throw_new("java/lang/RuntimeException", format!("Failed to get center field: {}", e));
+                return;
+            }
+        };
+
+        let center_x = match env.get_field(&center_obj, "x", "D").and_then(|v| v.d()) {
+            Ok(val) => val,
+            Err(e) => {
+                let _ = env.throw_new("java/lang/RuntimeException", format!("Failed to get center.x: {}", e));
+                return;
+            }
+        };
+
+        let center_y = match env.get_field(&center_obj, "y", "D").and_then(|v| v.d()) {
+            Ok(val) => val,
+            Err(e) => {
+                let _ = env.throw_new("java/lang/RuntimeException", format!("Failed to get center.y: {}", e));
+                return;
+            }
+        };
+
+        let radius = match env.get_field(&circle, "radius", "D").and_then(|v| v.d()) {
+            Ok(val) => val,
+            Err(e) => {
+                let _ = env.throw_new("java/lang/RuntimeException", format!("Failed to get radius: {}", e));
+                return;
+            }
+        };
+
+        ui.push(UiCommand::Circle {
+            id,
+            center_x,
+            center_y,
+            radius,
+        });
+    }
+
+    #[unsafe(no_mangle)]
+    pub extern "system" fn Java_com_dropbear_ui_UINative_wasClicked(
+        _env: JNIEnv,
+        _class: jni::objects::JClass,
+        ui_buffer_handle: jlong,
+        id: jlong,
+    ) -> jboolean {
+        let ui = convert_ptr!(ui_buffer_handle => UiContext);
+
+        let mut rendering = ui.currently_rendering.lock();
+        if let Some(response) = rendering.get(&(id as u64)) {
+            response.clicked().into()
+        } else {
+            false.into()
+        }
+    }
+}
\ No newline at end of file
diff --git a/crates/eucalyptus-core/src/ui/rect.rs b/crates/eucalyptus-core/src/ui/rect.rs
new file mode 100644
index 0000000..d31c48c
--- /dev/null
+++ b/crates/eucalyptus-core/src/ui/rect.rs
@@ -0,0 +1,202 @@
+use egui::{Stroke, StrokeKind};
+use jni::JNIEnv;
+use jni::objects::JObject;
+use crate::scripting::jni::utils::{FromJObject, ToJObject};
+use crate::scripting::result::DropbearNativeResult;
+use crate::scripting::native::DropbearNativeError;
+
+/// Maps directly to a `com.dropbear.ui.primitive.Rectangle` Kotlin class
+pub struct Rect {
+    pub id: u64,
+    pub initial_pos: (f32, f32),
+    pub size: (f32, f32),
+    pub corner_radius: f32,
+    pub stroke: Stroke,
+    pub fill: egui::Color32,
+    pub stroke_kind: StrokeKind,
+}
+
+impl FromJObject for Rect {
+    fn from_jobject(env: &mut JNIEnv, obj: &JObject) -> DropbearNativeResult<Self>
+    where
+        Self: Sized
+    {
+        let class = env
+            .find_class("com/dropbear/ui/primitive/Rectangle")
+            .map_err(|_| DropbearNativeError::JNIClassNotFound)?;
+
+        if !env
+            .is_instance_of(obj, &class)
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)?
+        {
+            return Err(DropbearNativeError::InvalidArgument);
+        }
+
+        // Get the ID field
+        let id_obj = env
+            .get_field(obj, "id", "Lcom/dropbear/utils/ID;")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .l()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)?;
+        
+        let id = env
+            .call_method(&id_obj, "getId", "()J", &[])
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .j()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)? as u64;
+
+        // Get initial (Vector2d)
+        let initial_obj = env
+            .get_field(obj, "initial", "Lcom/dropbear/math/Vector2d;")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .l()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)?;
+
+        let initial_x = env
+            .get_field(&initial_obj, "x", "D")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .d()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)? as f32;
+
+        let initial_y = env
+            .get_field(&initial_obj, "y", "D")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .d()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)? as f32;
+
+        // Get width and height
+        let width = env
+            .get_field(obj, "width", "D")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .d()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)? as f32;
+
+        let height = env
+            .get_field(obj, "height", "D")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .d()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)? as f32;
+
+        // Get corner radius
+        let corner_radius = env
+            .get_field(obj, "cornerRadius", "D")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .d()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)? as f32;
+
+        // Get stroke width
+        let stroke_width = env
+            .get_field(obj, "stroke", "D")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .d()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)? as f32;
+
+        // Get fill colour (Colour object)
+        let fill_colour_obj = env
+            .get_field(obj, "fillColour", "Lcom/dropbear/utils/Colour;")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .l()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)?;
+
+        let r = env
+            .get_field(&fill_colour_obj, "r", "B")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .b()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)? as u8;
+
+        let g = env
+            .get_field(&fill_colour_obj, "g", "B")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .b()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)? as u8;
+
+        let b = env
+            .get_field(&fill_colour_obj, "b", "B")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .b()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)? as u8;
+
+        let a = env
+            .get_field(&fill_colour_obj, "a", "B")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .b()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)? as u8;
+
+        let fill = egui::Color32::from_rgba_unmultiplied(r, g, b, a);
+
+        // Get stroke colour
+        let stroke_colour_obj = env
+            .get_field(obj, "strokeColour", "Lcom/dropbear/utils/Colour;")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .l()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)?;
+
+        let stroke_r = env
+            .get_field(&stroke_colour_obj, "r", "B")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .b()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)? as u8;
+
+        let stroke_g = env
+            .get_field(&stroke_colour_obj, "g", "B")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .b()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)? as u8;
+
+        let stroke_b = env
+            .get_field(&stroke_colour_obj, "b", "B")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .b()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)? as u8;
+
+        let stroke_a = env
+            .get_field(&stroke_colour_obj, "a", "B")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .b()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)? as u8;
+
+        let stroke_color = egui::Color32::from_rgba_unmultiplied(stroke_r, stroke_g, stroke_b, stroke_a);
+        let stroke = Stroke::new(stroke_width, stroke_color);
+
+        // Get stroke kind (enum)
+        let stroke_kind_obj = env
+            .get_field(obj, "strokeKind", "Lcom/dropbear/ui/StrokeKind;")
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .l()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)?;
+
+        let stroke_kind_name = env
+            .call_method(&stroke_kind_obj, "name", "()Ljava/lang/String;", &[])
+            .map_err(|_| DropbearNativeError::JNIFailedToGetField)?
+            .l()
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)?;
+
+        let stroke_kind_str: String = env
+            .get_string(&stroke_kind_name.into())
+            .map_err(|_| DropbearNativeError::JNIUnwrapFailed)?
+            .into();
+
+        let stroke_kind = match stroke_kind_str.as_str() {
+            "Inside" => StrokeKind::Inside,
+            "Middle" => StrokeKind::Middle,
+            "Outside" => StrokeKind::Outside,
+            _ => StrokeKind::Middle, // default
+        };
+
+        Ok(Rect {
+            id,
+            initial_pos: (initial_x, initial_y),
+            size: (width, height),
+            corner_radius,
+            stroke,
+            fill,
+            stroke_kind,
+        })
+    }
+}
+
+impl ToJObject for Rect {
+    fn to_jobject<'a>(&self, _env: &mut JNIEnv<'a>) -> DropbearNativeResult<JObject<'a>> {
+        todo!()
+    }
+}
\ No newline at end of file
diff --git a/crates/eucalyptus-core/src/utils.rs b/crates/eucalyptus-core/src/utils.rs
index 9a8dab0..0ceedb4 100644
--- a/crates/eucalyptus-core/src/utils.rs
+++ b/crates/eucalyptus-core/src/utils.rs
@@ -1,6 +1,7 @@
 //! Utility functions and helpers
 
 pub mod option;
+pub mod hashmap;
 
 use crate::states::Node;
 use dropbear_engine::utils::{ResourceReference, ResourceReferenceType, relative_path_from_euca};
diff --git a/crates/eucalyptus-core/src/utils/hashmap.rs b/crates/eucalyptus-core/src/utils/hashmap.rs
new file mode 100644
index 0000000..cd0c7c4
--- /dev/null
+++ b/crates/eucalyptus-core/src/utils/hashmap.rs
@@ -0,0 +1,247 @@
+use std::collections::HashMap;
+
+/// A wrapper for a [HashMap] that iterates its generation for each access. It is able to check
+/// for any stale values, and remove them in [StaleTracker::remove_stale].
+///
+/// # Types
+/// * `K` - The key type, must implement `Eq` and `Hash`
+/// * `V` - The value type
+///
+/// # Examples
+///
+/// ```
+/// let mut tracker = eucalyptus_core::utils::hashmap::StaleTracker::new();
+///
+/// // Insert some values
+/// tracker.insert("session_1", "user_data");
+/// tracker.insert("session_2", "other_data");
+///
+/// // Access session_1 to keep it fresh
+/// tracker.get(&"session_1");
+///
+/// // Advance time
+/// tracker.tick();
+///
+/// // session_2 hasn't been accessed, remove entries older than 0 generations
+/// let removed = tracker.remove_stale(0);
+/// assert_eq!(removed, vec!["session_2"]);
+/// ```
+pub struct StaleTracker<K, V> {
+    map: HashMap<K, (V, usize)>, // (value, last_access_generation)
+    current_generation: usize,
+}
+
+impl<K: Eq + std::hash::Hash, V> StaleTracker<K, V> {
+    /// Creates a new empty `StaleTracker`.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// let tracker: StaleTracker<String, i32> = StaleTracker::new();
+    /// ```
+    pub fn new() -> Self {
+        Self {
+            map: HashMap::new(),
+            current_generation: 0,
+        }
+    }
+
+    /// Inserts a key-value pair into the tracker.
+    ///
+    /// The entry is marked as accessed at the current generation. If the key already
+    /// exists, the old value is replaced and its access time is reset to the current generation.
+    ///
+    /// # Arguments
+    ///
+    /// * `key` - The key to insert
+    /// * `value` - The value to associate with the key
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// let mut tracker = StaleTracker::new();
+    /// tracker.insert("key", 42);
+    /// ```
+    pub fn insert(&mut self, key: K, value: V) {
+        self.map.insert(key, (value, self.current_generation));
+    }
+
+    /// Gets a reference to the value associated with the key.
+    ///
+    /// This method marks the entry as accessed at the current generation, preventing it
+    /// from being considered stale. Returns `None` if the key doesn't exist.
+    ///
+    /// # Arguments
+    ///
+    /// * `key` - The key to look up
+    ///
+    /// # Returns
+    ///
+    /// * `Some(&V)` if the key exists
+    /// * `None` if the key doesn't exist
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// let mut tracker = StaleTracker::new();
+    /// tracker.insert("key", 42);
+    ///
+    /// assert_eq!(tracker.get(&"key"), Some(&42));
+    /// assert_eq!(tracker.get(&"missing"), None);
+    /// ```
+    pub fn get(&mut self, key: &K) -> Option<&V> {
+        self.map.get_mut(key).map(|(value, generation)| {
+            *generation = self.current_generation;
+            &*value
+        })
+    }
+
+    /// Gets a mutable reference to the value associated with the key.
+    ///
+    /// This method marks the entry as accessed at the current generation. Returns `None`
+    /// if the key doesn't exist.
+    ///
+    /// # Arguments
+    ///
+    /// * `key` - The key to look up
+    ///
+    /// # Returns
+    ///
+    /// * `Some(&mut V)` if the key exists
+    /// * `None` if the key doesn't exist
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// let mut tracker = StaleTracker::new();
+    /// tracker.insert("counter", 0);
+    ///
+    /// if let Some(value) = tracker.get_mut(&"counter") {
+    ///     *value += 1;
+    /// }
+    /// ```
+    pub fn get_mut(&mut self, key: &K) -> Option<&mut V> {
+        self.map.get_mut(key).map(|(value, generation)| {
+            *generation = self.current_generation;
+            value
+        })
+    }
+
+    /// Advances the generation counter by one.
+    ///
+    /// Call this method periodically (e.g., once per frame, once per request, etc.) to
+    /// mark a new time period. Entries that aren't accessed after calling `tick()` will
+    /// age and eventually become stale.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// let mut tracker = StaleTracker::new();
+    /// tracker.insert("key", 42);
+    ///
+    /// // Simulate passage of time
+    /// tracker.tick();
+    /// tracker.tick();
+    ///
+    /// // "key" is now 2 generations old (hasn't been accessed since insertion)
+    /// ```
+    pub fn tick(&mut self) {
+        self.current_generation += 1;
+    }
+
+    /// Removes and returns all entries that haven't been accessed within `max_age` generations.
+    ///
+    /// An entry is considered stale if `(current_generation - last_access_generation) > max_age`.
+    ///
+    /// # Arguments
+    ///
+    /// * `max_age` - Maximum number of generations an entry can go without access before removal
+    ///
+    /// # Returns
+    ///
+    /// A vector of keys that were removed
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// let mut tracker = StaleTracker::new();
+    ///
+    /// tracker.insert("fresh", 1);
+    /// tracker.insert("stale", 2);
+    ///
+    /// tracker.get(&"fresh"); // Access this one
+    /// tracker.tick();        // Advance generation
+    /// tracker.tick();        // Advance again
+    ///
+    /// // "stale" hasn't been accessed in 2 generations
+    /// let removed = tracker.remove_stale(1);
+    /// assert!(removed.contains(&"stale"));
+    /// assert!(!removed.contains(&"fresh"));
+    /// ```
+    pub fn remove_stale(&mut self, max_age: usize) -> Vec<K>
+    where K: Clone
+    {
+        let current = self.current_generation;
+        let stale_keys: Vec<K> = self.map
+            .iter()
+            .filter(|(_, (_, generation))| current - generation > max_age)
+            .map(|(k, _)| k.clone())
+            .collect();
+
+        for key in &stale_keys {
+            self.map.remove(key);
+        }
+
+        stale_keys
+    }
+
+    /// Returns the number of entries currently in the tracker.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// let mut tracker = StaleTracker::new();
+    /// assert_eq!(tracker.len(), 0);
+    ///
+    /// tracker.insert("key", 42);
+    /// assert_eq!(tracker.len(), 1);
+    /// ```
+    pub fn len(&self) -> usize {
+        self.map.len()
+    }
+
+    /// Returns `true` if the tracker contains no entries.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// let tracker: StaleTracker<String, i32> = StaleTracker::new();
+    /// assert!(tracker.is_empty());
+    /// ```
+    pub fn is_empty(&self) -> bool {
+        self.map.is_empty()
+    }
+
+    /// Returns the current generation number.
+    ///
+    /// This can be useful for debugging or understanding how much time has passed.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// let mut tracker = StaleTracker::new();
+    /// assert_eq!(tracker.current_generation(), 0);
+    ///
+    /// tracker.tick();
+    /// assert_eq!(tracker.current_generation(), 1);
+    /// ```
+    pub fn current_generation(&self) -> usize {
+        self.current_generation
+    }
+}
+
+impl<K: Eq + std::hash::Hash, V> Default for StaleTracker<K, V> {
+    fn default() -> Self {
+        Self::new()
+    }
+}
\ No newline at end of file
diff --git a/crates/eucalyptus-editor/src/editor/mod.rs b/crates/eucalyptus-editor/src/editor/mod.rs
index 3b1cb2f..5367519 100644
--- a/crates/eucalyptus-editor/src/editor/mod.rs
+++ b/crates/eucalyptus-editor/src/editor/mod.rs
@@ -182,10 +182,26 @@ impl Editor {
 
         eucalyptus_core::utils::start_deadlock_detector();
 
-        let plugin_registry = PluginRegistry::new();
+        let mut plugin_registry = PluginRegistry::new();
+        if let Err(e) = plugin_registry.load_plugins() {
+            warn!("Failed to load plugins: {e}");
+        }
+
         let mut component_registry = ComponentRegistry::new();
+        register_components(&mut component_registry);
+
+        for plugin in plugin_registry.plugins.values_mut() {
+            let plugin_id = plugin.id().to_string();
+            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+                plugin.register_component(&mut component_registry);
+            }));
 
-        register_components(/*&mut plugin_registry,*/ &mut component_registry);
+            if result.is_ok() {
+                log::info!("Registered components for plugin '{plugin_id}'");
+            } else {
+                warn!("Plugin '{plugin_id}' panicked during component registration");
+            }
+        }
 
         let component_registry = Arc::new(component_registry);
 
diff --git a/crates/redback-runtime/src/scene.rs b/crates/redback-runtime/src/scene.rs
index cd2e8d8..ad37b95 100644
--- a/crates/redback-runtime/src/scene.rs
+++ b/crates/redback-runtime/src/scene.rs
@@ -446,6 +446,16 @@ impl Scene for PlayMode {
                             size: egui::vec2(display_width, display_height),
                         }));
                     });
+
+                    // render overlay
+                    egui::Area::new("overlay".into())
+                        .fixed_pos(egui::pos2(center_x, center_y))
+                        .show(&graphics.get_egui_context(), |o_ui| {
+                            // render the scripting overlay
+                            if let Err(e) = eucalyptus_core::ui::poll(o_ui) {
+                                log_once::error_once!("Unable to poll the UI: {}", e);
+                            }
+                        });
                 } else {
                     log::warn!("No such camera exists in the world");
                 }
diff --git a/scripting/commonMain/kotlin/com/dropbear/DropbearEngine.kt b/scripting/commonMain/kotlin/com/dropbear/DropbearEngine.kt
index de0043b..27bed1c 100644
--- a/scripting/commonMain/kotlin/com/dropbear/DropbearEngine.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/DropbearEngine.kt
@@ -4,6 +4,7 @@ import com.dropbear.asset.AssetHandle
 import com.dropbear.ffi.NativeEngine
 import com.dropbear.input.InputState
 import com.dropbear.scene.SceneManager
+import com.dropbear.ui.Ui
 
 internal var exceptionOnError: Boolean = false
 var lastErrorMessage: String? = null
@@ -17,6 +18,7 @@ var lastErrorMessage: String? = null
 class DropbearEngine(val native: NativeEngine) {
     val inputState: InputState = InputState()
     val sceneManager: SceneManager = SceneManager()
+    val ui: Ui = Ui()
 
     init {
         Companion.native = native
diff --git a/scripting/commonMain/kotlin/com/dropbear/EntityId.kt b/scripting/commonMain/kotlin/com/dropbear/EntityId.kt
index 19dd8f7..6a1aec3 100644
--- a/scripting/commonMain/kotlin/com/dropbear/EntityId.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/EntityId.kt
@@ -1,8 +1,10 @@
 package com.dropbear
 
+import com.dropbear.utils.ID
+
 /**
  * The ID of an entity (represented as a [Long])
  *
  * @property raw The entity into bits.
  */
-data class EntityId(val raw: Long)
\ No newline at end of file
+data class EntityId(val raw: Long): ID(raw)
\ No newline at end of file
diff --git a/scripting/commonMain/kotlin/com/dropbear/ui/Response.kt b/scripting/commonMain/kotlin/com/dropbear/ui/Response.kt
new file mode 100644
index 0000000..b4ddac7
--- /dev/null
+++ b/scripting/commonMain/kotlin/com/dropbear/ui/Response.kt
@@ -0,0 +1,62 @@
+package com.dropbear.ui
+
+import com.dropbear.input.MouseButton
+import com.dropbear.math.Vector2d
+import com.dropbear.utils.ID
+
+/**
+ * The response given by an element that is drawn.
+ */
+class Response(
+    val elementId: ID,
+    private val ui: Ui
+) {
+    fun clicked(): Boolean = ui.wasClicked(elementId)
+    fun clickedBy(button: MouseButton): Boolean = TODO("Not implemented yet")
+    fun secondaryClicked(): Boolean = TODO("Not implemented yet")
+    fun longTouched(): Boolean = TODO("Not implemented yet")
+    fun middleClicked(): Boolean = TODO("Not implemented yet")
+    fun doubleClicked(): Boolean = TODO("Not implemented yet")
+    fun tripleClicked(): Boolean = TODO("Not implemented yet")
+    fun doubleClickedBy(button: MouseButton): Boolean = TODO("Not implemented yet")
+    fun tripleClickedBy(button: MouseButton): Boolean = TODO("Not implemented yet")
+    fun clickedWithOpenInBackground(): Boolean = TODO("Not implemented yet")
+    fun clickedElsewhere(): Boolean = TODO("Not implemented yet")
+    fun enabled(): Boolean = TODO("Not implemented yet")
+    fun hovered(): Boolean = TODO("Not implemented yet")
+    fun hasFocus(): Boolean = TODO("Not implemented yet")
+    fun gainedFocus(): Boolean = TODO("Not implemented yet")
+    fun lostFocus() {}
+    fun requestFocus() {}
+    fun surrenderFocus() {}
+    fun dragStarted(): Boolean = TODO("Not implemented yet")
+    fun dragStartedBy(button: MouseButton): Boolean = TODO("Not implemented yet")
+    fun dragged(): Boolean = TODO("Not implemented yet")
+    fun draggedBy(button: MouseButton): Boolean = TODO("Not implemented yet")
+    fun dragStopped(): Boolean = TODO("Not implemented yet")
+    fun dragStoppedBy(button: MouseButton): Boolean = TODO("Not implemented yet")
+    fun dragDelta(): Vector2d = TODO("Not implemented yet")
+    fun totalDragDelta(): Vector2d = TODO("Not implemented yet")
+    fun dragMotion(): Vector2d = TODO("Not implemented yet")
+
+    // todo: payload
+
+    fun interactPointerPos(): Vector2d? = TODO("Not implemented yet")
+    fun hoverPos(): Vector2d = TODO("Not implemented yet")
+    fun isPointerButtonDownOn(): Boolean = TODO("Not implemented yet")
+    fun changed(): Boolean = TODO("Not implemented yet")
+    fun markChanged() {}
+    fun shouldClose(): Boolean = TODO("Not implemented yet")
+    fun setClose() {}
+    fun onHoverUi(ui: (Ui) -> Unit): Response = TODO("Not implemented yet")
+    fun onDisabledHoverUi(ui: (Ui) -> Unit) : Response = TODO("Not implemented yet")
+    fun onHoverUiAtPointer(ui: (Ui) -> Unit) : Response = TODO("Not implemented yet")
+    fun showTooltipUi(ui: (Ui) -> Unit) {}
+    fun showTooltipText(text: String) {}
+    fun isTooltipOpen(): Boolean = TODO("Not implemented yet")
+    fun onHoverTextAtPointer(text: String) {}
+    fun onHoverText(text: String) {}
+    fun onDisabledHoverText(text: String) {}
+    fun highlight(): Boolean = TODO("Not implemented yet")
+    fun interact(sense: Sense): Response = TODO("Not implemented yet")
+}
\ No newline at end of file
diff --git a/scripting/commonMain/kotlin/com/dropbear/ui/Sense.kt b/scripting/commonMain/kotlin/com/dropbear/ui/Sense.kt
new file mode 100644
index 0000000..f247beb
--- /dev/null
+++ b/scripting/commonMain/kotlin/com/dropbear/ui/Sense.kt
@@ -0,0 +1,69 @@
+package com.dropbear.ui
+
+/**
+ * What sort of interaction is a widget sensitive to?
+ */
+class Sense(val bit: Int) {
+    companion object {
+        const val HOVER = 0
+
+        /**
+         * Buttons, sliders, windows, …
+         */
+        const val CLICK = 1 shl 0
+
+        /**
+         * Sliders, windows, scroll bars, scroll areas, …
+         */
+        const val DRAG = 1 shl 1
+
+        /**
+         * This widget wants focus.
+         *
+         * Anything interactive + labels that can be focused for the benefit of screen readers.
+         */
+        const val FOCUSABLE = 1 shl 2
+
+        /**
+         * Senses no clicks or drags. Only senses mouse hover.
+         */
+        fun hover(): Sense {
+            return Sense(HOVER)
+        }
+
+        /**
+         * Senses no clicks or drags, but can be focused with the keyboard.
+         *
+         * Used for labels that can be focused for the benefit of screen readers.
+         */
+        fun focusableNonInteractive(): Sense {
+            return Sense(FOCUSABLE)
+        }
+
+        /**
+         * Sense clicks and hover, but not drags.
+         */
+        fun click(): Sense {
+            return Sense(CLICK or FOCUSABLE)
+        }
+
+        /**
+         * Sense drags and hover, but not clicks.
+         */
+        fun drag(): Sense {
+            return Sense(DRAG or FOCUSABLE)
+        }
+
+        /**
+         * Sense both clicks, drags and hover (e.g. a slider or window).
+         *
+         * Note that this will introduce a latency when dragging,
+         * because when the user starts a press egui can't know if this is the start
+         * of a click or a drag, and it won't know until the cursor has
+         * either moved a certain distance, or the user has released the mouse button.
+         */
+        fun clickAndDrag(): Sense {
+            return Sense(CLICK or FOCUSABLE or DRAG)
+        }
+    }
+}
\ No newline at end of file
diff --git a/scripting/commonMain/kotlin/com/dropbear/ui/StrokeKind.kt b/scripting/commonMain/kotlin/com/dropbear/ui/StrokeKind.kt
new file mode 100644
index 0000000..38663be
--- /dev/null
+++ b/scripting/commonMain/kotlin/com/dropbear/ui/StrokeKind.kt
@@ -0,0 +1,21 @@
+package com.dropbear.ui
+
+/**
+ * Describes how the stroke of a shape should be painted.
+ */
+enum class StrokeKind {
+    /**
+     * The stroke should be painted entirely inside the shape
+     */
+    Inside,
+
+    /**
+     * The stroke should be painted right on the edge of the shape, half inside and half outside.
+     */
+    Middle,
+
+    /**
+     * The stroke should be painted entirely outside the shape
+     */
+    Outside,
+}
\ No newline at end of file
diff --git a/scripting/commonMain/kotlin/com/dropbear/ui/Ui.kt b/scripting/commonMain/kotlin/com/dropbear/ui/Ui.kt
new file mode 100644
index 0000000..b75719a
--- /dev/null
+++ b/scripting/commonMain/kotlin/com/dropbear/ui/Ui.kt
@@ -0,0 +1,28 @@
+package com.dropbear.ui
+
+import com.dropbear.math.Vector2d
+import com.dropbear.ui.primitive.Circle
+import com.dropbear.ui.primitive.Rectangle
+import com.dropbear.utils.ID
+
+/**
+ * A command buffer to the 2D part of the game. Can be used for HUD and stuff.
+ *
+ * All content will be rendered the next frame.
+ */
+class Ui internal constructor() {
+    /**
+     * Draws/adds a [Widget] to the viewport. Typically used for HUD's and menus.
+     *
+     * The drawn widget will return a [Response], in which it is alive for 3 frames if
+     * not altered or rerendered.
+     */
+    fun add(widget: Widget): Response {
+        return widget.draw(this)
+    }
+}
+
+internal expect fun Ui.pushRect(rect: Rectangle)
+internal expect fun Ui.pushCircle(circle: Circle)
+
+internal expect fun Ui.wasClicked(id: ID): Boolean
\ No newline at end of file
diff --git a/scripting/commonMain/kotlin/com/dropbear/ui/Widget.kt b/scripting/commonMain/kotlin/com/dropbear/ui/Widget.kt
new file mode 100644
index 0000000..c055024
--- /dev/null
+++ b/scripting/commonMain/kotlin/com/dropbear/ui/Widget.kt
@@ -0,0 +1,13 @@
+package com.dropbear.ui
+
+import com.dropbear.utils.ID
+
+/**
+ * An abstract class all drawable objects must inherit
+ */
+abstract class Widget(id: ID) {
+    /**
+     * Draws the object.
+     */
+    abstract fun draw(ui: Ui) : Response
+}
\ No newline at end of file
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..be1a6d3
--- /dev/null
+++ b/scripting/commonMain/kotlin/com/dropbear/ui/WidgetType.kt
@@ -0,0 +1,6 @@
+package com.dropbear.ui
+
+object WidgetType {
+    const val RECTANGLE = 0
+    const val CIRCLE = 1
+}
\ No newline at end of file
diff --git a/scripting/commonMain/kotlin/com/dropbear/ui/primitive/Circle.kt b/scripting/commonMain/kotlin/com/dropbear/ui/primitive/Circle.kt
new file mode 100644
index 0000000..2e71d7e
--- /dev/null
+++ b/scripting/commonMain/kotlin/com/dropbear/ui/primitive/Circle.kt
@@ -0,0 +1,25 @@
+package com.dropbear.ui.primitive
+
+import com.dropbear.math.Vector2d
+import com.dropbear.ui.Response
+import com.dropbear.ui.Ui
+import com.dropbear.ui.Widget
+import com.dropbear.ui.pushCircle
+import com.dropbear.utils.ID
+
+/**
+ * Creates a standard circle.
+ *
+ * @property center The center of the circle.
+ * @property radius The distance from the perimeter of the circle to the center/the radius.
+ */
+class Circle(
+    val id: ID,
+    var center: Vector2d,
+    var radius: Double,
+): Widget(id) {
+    override fun draw(ui: Ui): Response {
+        ui.pushCircle(this)
+        return Response(id, ui)
+    }
+}
\ No newline at end of file
diff --git a/scripting/commonMain/kotlin/com/dropbear/ui/primitive/Rectangle.kt b/scripting/commonMain/kotlin/com/dropbear/ui/primitive/Rectangle.kt
new file mode 100644
index 0000000..146cb8e
--- /dev/null
+++ b/scripting/commonMain/kotlin/com/dropbear/ui/primitive/Rectangle.kt
@@ -0,0 +1,37 @@
+package com.dropbear.ui.primitive
+
+import com.dropbear.math.Vector2d
+import com.dropbear.ui.Response
+import com.dropbear.ui.StrokeKind
+import com.dropbear.ui.Ui
+import com.dropbear.ui.Widget
+import com.dropbear.ui.pushRect
+import com.dropbear.utils.Colour
+import com.dropbear.utils.ID
+
+/**
+ * @property initial The top left position/coordinate of the rectangle.
+ * @property width The width of the rectangle
+ * @property height The height of the rectangle
+ * @property cornerRadius The radius of the corner. The higher the value, the more smoothened out the rect is
+ * @property fillColour The colour of inside the rectangle. By default, it is [Colour.WHITE]
+ * @property stroke The thickness/width of the stroke.
+ * @property strokeColour The colour of the stroke. By default, it is [Colour.BLACK].
+ * @property strokeKind Describes the stroke of a shape
+ */
+class Rectangle(
+    val id: ID,
+    var initial: Vector2d,
+    var width: Double,
+    var height: Double,
+    var cornerRadius: Double = 0.0,
+    var fillColour: Colour = Colour.WHITE,
+    var stroke: Double = 0.0,
+    var strokeColour: Colour = Colour.BLACK,
+    var strokeKind: StrokeKind = StrokeKind.Middle,
+): Widget(id) {
+    override fun draw(ui: Ui): Response {
+        ui.pushRect(this)
+        return Response(id, ui)
+    }
+}
\ No newline at end of file
diff --git a/scripting/commonMain/kotlin/com/dropbear/utils/Colour.kt b/scripting/commonMain/kotlin/com/dropbear/utils/Colour.kt
index 009014b..b0c6930 100644
--- a/scripting/commonMain/kotlin/com/dropbear/utils/Colour.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/utils/Colour.kt
@@ -17,6 +17,62 @@ class Colour(
     var b: UByte,
     var a: UByte,
 ) {
+    companion object {
+        val WHITE = Colour(0u, 0u, 0u, 255u)
+        val BLACK = Colour(255u, 255u, 255u, 255u)
+
+        /**
+         * From a hex value such as `#ABC123`. It sanitises the input (removes the `#`)
+         * and converts it to an RGBA colour.
+         */
+        fun fromHex(hex: String): Colour {
+            val cleanHex = hex.removePrefix("#")
+
+            return when (cleanHex.length) {
+                6 -> {
+                    Colour(
+                        r = cleanHex.substring(0, 2).toUByte(16),
+                        g = cleanHex.substring(2, 4).toUByte(16),
+                        b = cleanHex.substring(4, 6).toUByte(16),
+                        a = 255u
+                    )
+                }
+                8 -> {
+                    Colour(
+                        r = cleanHex.substring(0, 2).toUByte(16),
+                        g = cleanHex.substring(2, 4).toUByte(16),
+                        b = cleanHex.substring(4, 6).toUByte(16),
+                        a = cleanHex.substring(6, 8).toUByte(16)
+                    )
+                }
+                3 -> {
+                    Colour(
+                        r = cleanHex.substring(0, 1).repeat(2).toUByte(16),
+                        g = cleanHex.substring(1, 2).repeat(2).toUByte(16),
+                        b = cleanHex.substring(2, 3).repeat(2).toUByte(16),
+                        a = 255u
+                    )
+                }
+                4 -> {
+                    Colour(
+                        r = cleanHex.substring(0, 1).repeat(2).toUByte(16),
+                        g = cleanHex.substring(1, 2).repeat(2).toUByte(16),
+                        b = cleanHex.substring(2, 3).repeat(2).toUByte(16),
+                        a = cleanHex.substring(3, 4).repeat(2).toUByte(16)
+                    )
+                }
+                else -> throw IllegalArgumentException("Invalid hex color format: $hex")
+            }
+        }
+
+        /**
+         * Converts a Double to a UByte for each colour and creates a new Colour object.
+         */
+        fun fromDouble(r: Double, g: Double, b: Double, a: Double): Colour {
+            return Colour(r.toInt().toUByte(), g.toInt().toUByte(), b.toInt().toUByte(), a.toInt().toUByte())
+        }
+    }
+
     /**
      * Divides all values by `255` and creates a new [Vector4d] object.
      */
diff --git a/scripting/commonMain/kotlin/com/dropbear/utils/ID.kt b/scripting/commonMain/kotlin/com/dropbear/utils/ID.kt
new file mode 100644
index 0000000..6caa08b
--- /dev/null
+++ b/scripting/commonMain/kotlin/com/dropbear/utils/ID.kt
@@ -0,0 +1,34 @@
+package com.dropbear.utils
+
+/**
+ * Describes all classes that can be supplied as a form of identification, such as that of a hashcode
+ * or a number.
+ *
+ * This is different to [com.dropbear.asset.Handle] as a handle is used for assets, while an [ID] is used
+ * for identifying something.
+ *
+ * @param id The raw id value
+ */
+open class ID(id: Long) {
+    companion object {
+        fun fromString(id: String): ID {
+            return ID(hashCode().toLong())
+        }
+    }
+
+    // it has to be like this EntityId already took `raw`.
+    private val rawId: Long = id
+
+    fun getId(): Long = rawId
+
+    override fun toString(): String {
+        return "ID(raw=${getId()})"
+    }
+}
+
+/**
+ * Converts a [String] into an [ID], allowing to be used for UI.
+ */
+fun String.asId(): ID {
+    return ID(hashCode().toLong())
+}
\ No newline at end of file
diff --git a/scripting/jvmMain/java/com/dropbear/ui/UINative.java b/scripting/jvmMain/java/com/dropbear/ui/UINative.java
new file mode 100644
index 0000000..1e920e4
--- /dev/null
+++ b/scripting/jvmMain/java/com/dropbear/ui/UINative.java
@@ -0,0 +1,15 @@
+package com.dropbear.ui;
+
+import com.dropbear.EucalyptusCoreLoader;
+import com.dropbear.ui.primitive.Circle;
+import com.dropbear.ui.primitive.Rectangle;
+
+public class UINative {
+    static {
+        new EucalyptusCoreLoader().ensureLoaded();
+    }
+
+    public static native void pushRect(long uiBufferHandle, Rectangle rect);
+    public static native void pushCircle(long uiBufferHandle, Circle circle);
+    public static native boolean wasClicked(long uiBufferHandle, long id);
+}
diff --git a/scripting/jvmMain/kotlin/com/dropbear/ffi/DropbearContext.kt b/scripting/jvmMain/kotlin/com/dropbear/ffi/DropbearContext.kt
index 3804406..4ed40fd 100644
--- a/scripting/jvmMain/kotlin/com/dropbear/ffi/DropbearContext.kt
+++ b/scripting/jvmMain/kotlin/com/dropbear/ffi/DropbearContext.kt
@@ -10,4 +10,5 @@ class DropbearContext(
     val assetHandle: Long,
     val sceneLoaderHandle: Long,
     val physicsEngineHandle: Long,
+    val uiHandle: Long,
 )
\ No newline at end of file
diff --git a/scripting/jvmMain/kotlin/com/dropbear/ffi/NativeEngine.jvm.kt b/scripting/jvmMain/kotlin/com/dropbear/ffi/NativeEngine.jvm.kt
index 9bcb833..5acd8f6 100644
--- a/scripting/jvmMain/kotlin/com/dropbear/ffi/NativeEngine.jvm.kt
+++ b/scripting/jvmMain/kotlin/com/dropbear/ffi/NativeEngine.jvm.kt
@@ -9,6 +9,7 @@ actual class NativeEngine {
     internal var assetHandle: Long = 0L
     internal var sceneLoaderHandle: Long = 0L
     internal var physicsEngineHandle: Long = 0L
+    internal var uiBufferHandle: Long = 0L
 
     @JvmName("init")
     fun init(ctx: DropbearContext) {
@@ -18,6 +19,7 @@ actual class NativeEngine {
         this.assetHandle = ctx.assetHandle
         this.sceneLoaderHandle = ctx.sceneLoaderHandle
         this.physicsEngineHandle = ctx.physicsEngineHandle
+        this.uiBufferHandle = ctx.uiHandle
 
         if (this.worldHandle <= 0L) {
             Logger.error("NativeEngine: Error - Invalid world handle received!")
@@ -43,5 +45,9 @@ actual class NativeEngine {
             Logger.error("NativeEngine: Error - Invalid physics handle received!")
             return
         }
+        if (this.uiBufferHandle <= 0L) {
+            Logger.error("NativeEngine: Error - Invalid ui command buffer handle received!")
+            return
+        }
     }
 }
\ No newline at end of file
diff --git a/scripting/jvmMain/kotlin/com/dropbear/ui/Ui.jvm.kt b/scripting/jvmMain/kotlin/com/dropbear/ui/Ui.jvm.kt
new file mode 100644
index 0000000..fd58d92
--- /dev/null
+++ b/scripting/jvmMain/kotlin/com/dropbear/ui/Ui.jvm.kt
@@ -0,0 +1,24 @@
+package com.dropbear.ui
+
+import com.dropbear.DropbearEngine
+import com.dropbear.ui.primitive.Circle
+import com.dropbear.ui.primitive.Rectangle
+import com.dropbear.utils.ID
+
+internal actual fun Ui.wasClicked(id: ID): Boolean {
+    return UINative.wasClicked(DropbearEngine.native.uiBufferHandle, id.getId())
+}
+
+internal actual fun Ui.pushRect(rect: Rectangle) {
+    UINative.pushRect(
+        DropbearEngine.native.uiBufferHandle,
+        rect
+    )
+}
+
+internal actual fun Ui.pushCircle(circle: Circle) {
+    UINative.pushCircle(
+        DropbearEngine.native.uiBufferHandle,
+        circle
+    )
+}
\ No newline at end of file
diff --git a/scripting/nativeMain/kotlin/com/dropbear/ffi/NativeEngine.native.kt b/scripting/nativeMain/kotlin/com/dropbear/ffi/NativeEngine.native.kt
index 61d52e1..bdc6f7a 100644
--- a/scripting/nativeMain/kotlin/com/dropbear/ffi/NativeEngine.native.kt
+++ b/scripting/nativeMain/kotlin/com/dropbear/ffi/NativeEngine.native.kt
@@ -19,6 +19,7 @@ actual class NativeEngine {
     private var assetHandle: COpaquePointer? = null
     private var sceneLoaderHandle: COpaquePointer? = null
     private var physicsEngineHandle: COpaquePointer? = null
+    private var uiBufferHandle: COpaquePointer? = null
 
     @Suppress("unused")
     fun init(
@@ -30,6 +31,7 @@ actual class NativeEngine {
         this.assetHandle = ctx?.assets?.rawValue?.let { interpretCPointer(it) }
         this.sceneLoaderHandle = ctx?.scene_loader?.rawValue?.let { interpretCPointer(it) }
         this.physicsEngineHandle = ctx?.physics_engine?.rawValue?.let { interpretCPointer(it) }
+        this.uiBufferHandle = ctx?.graphics?.rawValue?.let { interpretCPointer(it) }
 
         // if release, always enable exceptionOnError
         if (!Platform.isDebugBinary) {
@@ -66,5 +68,11 @@ actual class NativeEngine {
                 throw DropbearNativeException("init failed - Invalid physics engine handle received!")
             }
         }
+        if (this.uiBufferHandle == null) {
+            Logger.error("NativeEngine: Error - Invalid ui command buffer engine handle received!")
+            if (exceptionOnError) {
+                throw DropbearNativeException("init failed - Invalid ui command buffer engine handle received!")
+            }
+        }
     }
 }
\ No newline at end of file
diff --git a/scripting/nativeMain/kotlin/com/dropbear/ui/Ui.native.kt b/scripting/nativeMain/kotlin/com/dropbear/ui/Ui.native.kt
new file mode 100644
index 0000000..e730a47
--- /dev/null
+++ b/scripting/nativeMain/kotlin/com/dropbear/ui/Ui.native.kt
@@ -0,0 +1,18 @@
+package com.dropbear.ui
+
+import com.dropbear.DropbearEngine
+import com.dropbear.math.Vector2d
+import com.dropbear.ui.primitive.Circle
+import com.dropbear.ui.primitive.Rectangle
+import com.dropbear.utils.ID
+
+internal actual fun Ui.wasClicked(id: ID): Boolean {
+    TODO("Not yet implemented")
+}
+internal actual fun Ui.pushRect(rect: Rectangle) {
+    TODO("Not yet implemented")
+}
+
+internal actual fun Ui.pushCircle(circle: Circle) {
+    TODO("Not yet implemented")
+}
\ No newline at end of file