diff --git a/engine/physics/src/physics.rs b/engine/physics/src/physics.rs
index 8644cb3..96bc0fa 100644
--- a/engine/physics/src/physics.rs
+++ b/engine/physics/src/physics.rs
@@ -1,4 +1,5 @@
 use std::collections::{HashMap,HashSet};
+use crate::model::DirectedEdge;
 use crate::model::{self as model_physics,PhysicsMesh,PhysicsMeshTransform,TransformedMesh,MeshQuery,PhysicsMeshId,PhysicsSubmeshId};
 use strafesnet_common::bvh;
 use strafesnet_common::map;
@@ -980,6 +981,34 @@ impl PhysicsContext<'_>{
 	}
 }
 impl PhysicsData{
+	pub fn trace_ray(&self,ray:strafesnet_common::ray::Ray)->Option<ModelId>{
+		let (_time,convex_mesh_id)=self.bvh.sample_ray(&ray,Time::ZERO,Time::MAX/4,|&model,ray|{
+			let mesh=self.models.mesh(model);
+			// brute force trace every face
+			mesh.faces().filter_map(|face_id|{
+				let (n,d)=mesh.face_nd(face_id);
+				// trace ray onto face
+				// n.(o+d*t)==n.p
+				// n.o + n.d * t == n.p
+				// t == (n.p - n.o)/n.d
+				let nd=n.dot(ray.direction);
+				if nd.is_zero(){
+					return None;
+				}
+				let t=(d-n.dot(ray.origin))/nd;
+				// check if point of intersection is behind face edges
+				// *2 because average of 2 vertices
+				let p=ray.extrapolate(t)*2;
+				mesh.face_edges(face_id).iter().all(|&directed_edge_id|{
+					let edge_n=mesh.directed_edge_n(directed_edge_id);
+					let cross_n=edge_n.cross(n);
+					let &[vert0,vert1]=mesh.edge_verts(directed_edge_id.as_undirected()).as_ref();
+					cross_n.dot(p)<cross_n.dot(mesh.vert(vert0)+mesh.vert(vert1))
+				}).then(||t)
+			}).min().map(Into::into)
+		})?;
+		Some(convex_mesh_id.model_id.into())
+	}
 	/// use with caution, this is the only non-instruction way to mess with physics
 	pub fn generate_models(&mut self,map:&map::CompleteMap){
 		let mut modes=map.modes.clone().denormalize();