diff --git a/demo/demo/rollball.cpp b/demo/demo/rollball.cpp
index 24b50bf9b97b8d0286047485894715baffca5382..4f810142fe50e837e14c00fb33b73569b626c526 100644
--- a/demo/demo/rollball.cpp
+++ b/demo/demo/rollball.cpp
@@ -93,6 +93,7 @@ static void setupPrefabs(fggl::ecs3::World& world, Prefabs& prefabs) {
 		// player (cube because my sphere function doesn't exist yet
 		prefabs.player = world.create(true);
 		world.add<fggl::math::Transform>(prefabs.player);
+		world.add<fggl::phys::Dynamics>(prefabs.player);
 		world.add<fggl::phys::RigidBody>(prefabs.player);
 
 		fggl::data::StaticMesh meshComponent;
@@ -110,6 +111,8 @@ static void setupPrefabs(fggl::ecs3::World& world, Prefabs& prefabs) {
 		meshComponent.pipeline = "phong";
 		fggl::data::make_cube(meshComponent.mesh);
 		world.set<fggl::data::StaticMesh>(prefabs.collectable, &meshComponent);
+
+		auto* callbacks = world.add<fggl::phys::CollisionCallbacks>(prefabs.collectable);
 	}
 
 }
@@ -136,7 +139,7 @@ static void setupCamera(fggl::ecs3::World& world) {
 	}
 }
 
-static void setupEnvironment(fggl::ecs3::World& world, const Prefabs prefabs, fggl::math::vec2& size) {
+static fggl::ecs3::entity_t setupEnvironment(fggl::ecs3::World& world, const Prefabs prefabs, fggl::math::vec2& size) {
 	{
 		auto northWall = world.copy(prefabs.wallX);
 		auto* transform = world.get<fggl::math::Transform>(northWall);
@@ -167,17 +170,18 @@ static void setupEnvironment(fggl::ecs3::World& world, const Prefabs prefabs, fg
 		transform->origin({0.0f, -2.5f, 0.0f});
 	}
 
+	auto player = fggl::ecs3::NULL_ENTITY;
 	{
 		// player just starts off as the prefab dictates
-		auto player = world.copy(prefabs.player);
+		player = world.copy(prefabs.player);
 	}
 
 	{
 		// collectables
 		std::array<fggl::math::vec3, 3> collectPos {{
-			{-5.0f, 0.0f, 12.0f},
-			{15.0f, 0.0f, 0.5f},
-			{6.0f, 0.0f, -15.0f}
+			{-5.0f, -0.5f, 12.0f},
+			{15.0f, -0.5f, 0.5f},
+			{6.0f, -0.5f, -15.0f}
 		}};
 
 		// build the collectables
@@ -187,6 +191,8 @@ static void setupEnvironment(fggl::ecs3::World& world, const Prefabs prefabs, fg
 			transform->origin(pos);
 		}
 	}
+
+	return player;
 }
 
 enum camera_type { cam_free, cam_arcball };
@@ -209,9 +215,9 @@ static void process_camera(fggl::ecs3::World& ecs, const fggl::input::Input& inp
 		motion -= (forward * delta);
 	camTransform->origin( camTransform->origin() + motion );
 
-	if ( cam_mode == cam_arcball || input.mouse.down( fggl::input::MouseButton::MIDDLE ) ) {
+	if ( input.mouse.down( fggl::input::MouseButton::MIDDLE ) ) {
 		fggl::input::process_arcball(ecs, input, cam);
-	} else if ( cam_mode == cam_free ) {
+	} else {
 		fggl::input::process_freecam(ecs, input, cam);
 	}
 	fggl::input::process_edgescroll( ecs, input, cam );
@@ -233,12 +239,64 @@ namespace demo {
 
 		// create a 20x20 grid
 		fggl::math::vec2 size{40.0f, 40.0f};
-		setupEnvironment(world(), prefabs, size);
+		player = setupEnvironment(world(), prefabs, size);
 	}
 
 	void RollBall::update() {
 		Game::update();
-		process_camera(world(), input());
+		auto& input = this->input();
+
+		if ( input.keyboard.down(glfwGetKeyScancode(GLFW_KEY_F1))) {
+			process_camera(world(), input);
+		} else {
+			if ( player != fggl::ecs3::NULL_ENTITY ) {
+				auto &world = this->world();
+
+				const fggl::math::vec3 forward = fggl::math::FORWARD;
+				const fggl::math::vec3 right = fggl::math::RIGHT;
+				fggl::math::vec3 force = fggl::math::VEC3_ZERO;
+				bool moved = false;
+
+				if (input.keyboard.down(glfwGetKeyScancode(GLFW_KEY_W))) {
+					force += forward;
+					moved = true;
+				}
+
+				if (input.keyboard.down(glfwGetKeyScancode(GLFW_KEY_S))) {
+					force -= forward;
+					moved = true;
+				}
+
+				if (input.keyboard.down(glfwGetKeyScancode(GLFW_KEY_A))) {
+					force -= right;
+					moved = true;
+				}
+
+				if (input.keyboard.down(glfwGetKeyScancode(GLFW_KEY_D))) {
+					force += right;
+					moved = true;
+				}
+
+				// setup dynamic force
+				if ( moved ) {
+					float forceFactor = 3.0F;
+					force = glm::normalize(force) * forceFactor;
+					auto *dynamics = world.get<fggl::phys::Dynamics>(player);
+					dynamics->force += force;
+				}
+
+				{
+					auto cameras = world.findMatching<fggl::gfx::Camera>();
+					fggl::ecs3::entity_t cam = cameras[0];
+					auto *camComp = world.get<fggl::gfx::Camera>(cam);
+					camComp->target = world.get<fggl::math::Transform>(player)->origin();
+
+					auto *camTransform = world.get<fggl::math::Transform>(cam);
+					camTransform->origin( camComp->target - (fggl::math::FORWARD * 15.0F) + (fggl::math::UP * 15.0F) );
+				}
+
+			}
+		}
 	}
 
 	void RollBall::render(fggl::gfx::Graphics &gfx) {
diff --git a/demo/include/rollball.hpp b/demo/include/rollball.hpp
index 755c9fb88db7385c6911778b505eab64632a2c5b..73821dfbb569a01f0d665b2581c81b3a96eb664b 100644
--- a/demo/include/rollball.hpp
+++ b/demo/include/rollball.hpp
@@ -38,6 +38,9 @@ namespace demo {
 
 			void update() override;
 			void render(fggl::gfx::Graphics& gfx) override;
+
+		private:
+			fggl::ecs3::entity_t player = fggl::ecs3::NULL_ENTITY;
 	};
 
 }
diff --git a/fggl/phys/bullet/simulation.cpp b/fggl/phys/bullet/simulation.cpp
index 381ac7356aa094fb5253361f7a7e55322aaf8f2e..9e028e0b36fd033cdbe0d7bf63c08cd878d2b2cb 100644
--- a/fggl/phys/bullet/simulation.cpp
+++ b/fggl/phys/bullet/simulation.cpp
@@ -37,8 +37,24 @@ namespace fggl::phys::bullet {
 
 	void BulletPhysicsEngine::step() {
 		checkForPhys();
+
+		auto dynamicEnts = m_ecs->findMatching<phys::Dynamics>();
+		for (auto& ent : dynamicEnts) {
+			auto* dynamicComp = m_ecs->get<phys::Dynamics>(ent);
+			auto* bulletProxy = m_ecs->get<phys::bullet::BulletBody>(ent);
+			if ( bulletProxy == nullptr ) {
+				std::cerr << "entity has dynamics but isn't a physics object!" << std::endl;
+				continue;
+			}
+
+			const auto forceVec = dynamicComp->force;
+			bulletProxy->body->applyCentralForce( {forceVec.x, forceVec.y, forceVec.z} );
+			dynamicComp->force = math::VEC3_ZERO;
+		}
+
 		m_world->stepSimulation(60.0f);
 		syncToECS();
+		dealWithCollisions();
 	}
 
 	static void build_motation_state(math::Transform* myState, BulletBody* btState) {
@@ -107,19 +123,21 @@ namespace fggl::phys::bullet {
 				colShape,
 				localInt
 			);
-			btComp->body = new btRigidBody(bodyCI);
+			auto* body = new btRigidBody(bodyCI);
+			body->setUserIndex(static_cast<int>(ent));
+
+			btComp->body = body;
 
 			if (!fgBody->isStatic()) {
-				// FIXME this is for testing, needs to be removed
-				btComp->body->setRestitution(0.5f);
-				btComp->body->setRollingFriction(0.0f);
+				body->setRestitution(0.5f);
+				body->setRollingFriction(0.0f);
 			}
-			m_world->addRigidBody(btComp->body);
 
-			if ( !fgBody->isStatic() ) {
-				btComp->body->applyTorque({50.0f, 0.5f, 0.5f});
-				btComp->body->applyImpulse({5.0f, 0.5f, 0.5f}, {0.0f, 0.0f, 0.0f});
+			if ( fgBody->type == BodyType::KINEMATIC ) {
+				body->setCollisionFlags( body->getCollisionFlags() | btCollisionObject::CF_KINEMATIC_OBJECT );
 			}
+
+			m_world->addRigidBody(btComp->body );
 		}
 	}
 
@@ -165,4 +183,69 @@ namespace fggl::phys::bullet {
 		delete m_config.collisionConfiguration;
 	}
 
+	static void handleCollisionCallbacks(ecs3::World* world, ecs3::entity_t owner, ecs3::entity_t other) {
+		auto* callbacks = world->tryGet<CollisionCallbacks>(owner);
+		if ( callbacks == nullptr ) {
+			return;
+		}
+
+		auto* cache = world->tryGet<CollisionCache>(owner);
+		if ( cache != nullptr ) {
+			auto itr = cache->collisions.find(other);
+			if ( itr == cache->collisions.end() ) {
+				if ( callbacks->onEnter != nullptr ) {
+					callbacks->onEnter(other);
+				}
+				cache->collisions.insert( other );
+			} else {
+				if ( callbacks->onStay != nullptr ) {
+					callbacks->onStay(other);
+				}
+			}
+		}
+	}
+
+	void BulletPhysicsEngine::dealWithCollisions() {
+		// flush collision caches
+		auto caches = m_ecs->findMatching<CollisionCache>();
+		for( auto& ent : caches) {
+			auto* cache = m_ecs->get<CollisionCache>(ent);
+			std::swap(cache->collisions, cache->lastFrame);
+			cache->collisions.clear();
+		}
+
+		// deal with the number of manifolds
+		const auto numManifolds = m_config.dispatcher->getNumManifolds();
+		for ( int i=0; i< numManifolds; ++i) {
+			auto* contactManifold = m_config.dispatcher->getManifoldByIndexInternal(i);
+
+			const auto* body0 = contactManifold->getBody0();
+			const auto* body1 = contactManifold->getBody1();
+
+			handleCollisionCallbacks(m_ecs, body0->getUserIndex(), body1->getUserIndex());
+			handleCollisionCallbacks(m_ecs, body1->getUserIndex(), body0->getUserIndex());
+
+			std::cerr << "contact: " << body0->getUserIndex() << " on " << body1->getUserIndex() << std::endl;
+		}
+
+		// note conacts that have ended
+		for( auto& ent : caches) {
+			auto* cache = m_ecs->get<CollisionCache>(ent);
+
+			// if we don't have an exit callback, it's pointless checking the cache
+			auto* callbacks = m_ecs->tryGet<CollisionCallbacks>(ent);
+			if ( callbacks == nullptr || callbacks->onExit == nullptr){
+				continue;
+			}
+
+			// check if a collision has ended
+			for (const auto& other : cache->lastFrame) {
+				auto itr = cache->collisions.find(other);
+				if (itr == cache->collisions.end()) {
+					callbacks->onExit(other);
+				}
+			}
+		}
+	}
+
 } // namespace fggl::phys::bullet
\ No newline at end of file
diff --git a/include/fggl/ecs3/prototype/world.h b/include/fggl/ecs3/prototype/world.h
index 75969c3b6500a4247cd495d6fead468226e552dc..7b633487bbf97c837c92dd0933806fdd6a2625e6 100644
--- a/include/fggl/ecs3/prototype/world.h
+++ b/include/fggl/ecs3/prototype/world.h
@@ -205,13 +205,12 @@ namespace fggl::ecs3::prototype {
 			}
 
 			template<typename C>
-			C *get(entity_t entity_id) const {
+			C* tryGet(entity_t entity_id) const {
 				try {
-					auto &entity = m_entities.at(entity_id);
+					const auto &entity = m_entities.at(entity_id);
 					try {
 						return entity.get<C>();
 					} catch ( std::out_of_range& e ) {
-						std::cerr << "entity " << entity_id << " does not have component "<< C::name << std::endl;
 						return nullptr;
 					}
 				} catch ( std::out_of_range& e) {
@@ -220,6 +219,15 @@ namespace fggl::ecs3::prototype {
 				}
 			}
 
+			template<typename C>
+			C *get(entity_t entity_id) const {
+				C* ptr = tryGet<C>(entity_id);
+				if (ptr == nullptr) {
+					std::cerr << "entity " << entity_id << " does not have component "<< C::name << std::endl;
+				}
+				return ptr;
+			}
+
 			template<typename C>
 			void remove(entity_t entity_id) {
 				try {
diff --git a/include/fggl/phys/bullet/bullet.hpp b/include/fggl/phys/bullet/bullet.hpp
index d37779674454495075997463dbb0d21745ebd8e3..b50326c6f321454cb66f6b5ef3f962e033cf7832 100644
--- a/include/fggl/phys/bullet/bullet.hpp
+++ b/include/fggl/phys/bullet/bullet.hpp
@@ -48,8 +48,11 @@ namespace fggl::phys::bullet {
 				return "phys::Bullet3";
 			}
 
-			void onLoad(ecs3::ModuleManager &manager, ecs3::TypeRegistry &types) override {
+			void onLoad(ecs3::ModuleManager & /*manager*/, ecs3::TypeRegistry &types) override {
 				// dependencies
+				types.make<phys::CollisionCallbacks>();
+				types.make<phys::CollisionCache>();
+
 				types.make<phys::RigidBody>();
 				types.make<phys::Dynamics>();
 
diff --git a/include/fggl/phys/bullet/types.hpp b/include/fggl/phys/bullet/types.hpp
index 31d9a94491aaf872497590862d62065763d57727..f06b858fb658fd86c436a5890d2eb6e764c31941 100644
--- a/include/fggl/phys/bullet/types.hpp
+++ b/include/fggl/phys/bullet/types.hpp
@@ -75,6 +75,11 @@ namespace fggl::phys::bullet {
 			 * Sync the bullet world state back to the ECS.
 			 */
 			void syncToECS();
+
+			/**
+			 * Deal with physics collisions
+			 */
+			void dealWithCollisions();
 	};
 
 } // namespace fggl::phys::bullet
diff --git a/include/fggl/phys/callbacks.hpp b/include/fggl/phys/callbacks.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..cc14483fb26359aa77ef5a50566fa6357d74b70c
--- /dev/null
+++ b/include/fggl/phys/callbacks.hpp
@@ -0,0 +1,50 @@
+/*
+ * ${license.title}
+ * Copyright (C) 2022 ${license.owner}
+ * ${license.mailto}
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+//
+// Created by webpigeon on 30/04/22.
+//
+
+#ifndef FGGL_PHYS_CALLBACKS_HPP
+#define FGGL_PHYS_CALLBACKS_HPP
+
+#include <functional>
+#include <unordered_set>
+
+namespace fggl::phys {
+
+	using CollisionCB = std::function<void(ecs3::entity_t)>;
+
+	struct CollisionCallbacks {
+		constexpr static const char* name = "phys::Callbacks";
+		CollisionCB onEnter = nullptr;
+		CollisionCB onExit = nullptr;
+		CollisionCB onStay = nullptr;
+	};
+
+	struct CollisionCache {
+		constexpr static const char* name = "phys::Cache";
+		std::unordered_set<ecs3::entity_t> collisions;
+		std::unordered_set<ecs3::entity_t> lastFrame;
+	};
+
+} // namespace fggl::phys
+
+#endif //FGGL_PHYS_CALLBACKS_HPP
diff --git a/include/fggl/phys/types.hpp b/include/fggl/phys/types.hpp
index 4f06ade6d77f502fd76b6c22e46c7139d4750195..3b15ec6fe7e10346232459aebaf285d0e78b9f91 100644
--- a/include/fggl/phys/types.hpp
+++ b/include/fggl/phys/types.hpp
@@ -25,6 +25,8 @@
 #ifndef FGGL_PHYS_TYPES_HPP
 #define FGGL_PHYS_TYPES_HPP
 
+#include "fggl/phys/callbacks.hpp"
+
 namespace fggl::phys {
 	constexpr float MASS_STATIC = 0.0F;
 	constexpr float MASS_DEFAULT = 1.0F;
@@ -52,10 +54,15 @@ namespace fggl::phys {
 		explicit inline Sphere(float rad) : PhyShape(ShapeType::SPHERE), radius(rad) {}
 	};
 
+	enum class BodyType {
+			STATIC, KINEMATIC, DYNAMIC
+	};
+
 	struct RigidBody {
 		constexpr static const char* name = "phys::Body";
 		float mass = MASS_DEFAULT;
 		PhyShape* shape = nullptr;
+		BodyType type = BodyType::DYNAMIC;
 
 		[[nodiscard]]
 		inline bool isStatic() const {