diff --git a/demo/demo/GameScene.cpp b/demo/demo/GameScene.cpp
index 165c4b99fa881fc35509e415d8fde2d99615efde..1bb95b57c96dcfe6eb903038ba5bb334ce32e08a 100644
--- a/demo/demo/GameScene.cpp
+++ b/demo/demo/GameScene.cpp
@@ -172,8 +172,8 @@ void GameScene::setup() {
 	}
 }
 
-void GameScene::update() {
-	Game::update();
+void GameScene::update(float dt) {
+	Game::update(dt);
 	process_camera(world(), input());
 }
 
diff --git a/demo/demo/grid.cpp b/demo/demo/grid.cpp
index 670bbb7082315e6a1dc3799f9aa4790f114e53a7..16cec01e9a5032b3fa3f37d2dd7a4907a948edad 100644
--- a/demo/demo/grid.cpp
+++ b/demo/demo/grid.cpp
@@ -94,13 +94,15 @@ namespace demo {
 		}
 	}
 
-	GridScene::GridScene(fggl::App &app) : GameBase(app), m_tiles(), m_grid(nullptr) {
+	GridScene::GridScene(fggl::App &app) : GameBase(app), m_tiles(), m_animator(15.0F), m_grid(nullptr) {
+		m_animator.add([this](){this->tickPlayer();});
 	}
 
 	void GridScene::activate() {
 		GameBase::activate();
 		fggl::debug::log(fggl::debug::Level::info, "GridScene::activate()");
 
+		m_animator.reset();
 
 		// fake loading the tileset
 		if ( m_tiles.m_floors.empty() ) {
@@ -175,6 +177,20 @@ namespace demo {
 		}
 	}
 
+	void GridScene::update(float deltaTime) {
+		GameBase::update(deltaTime);
+		m_animator.update(deltaTime);
+	}
+
+	void GridScene::tickPlayer() {
+		auto &manager = m_grid->entities();
+		auto entities = manager.find<CellPos>();
+		for (const auto &entity : entities) {
+			auto &pos = manager.get<CellPos>(entity);
+			pos.direction = (pos.direction + 1) % 4;
+		}
+	}
+
 	//float progress = 0.0f;
 	void GridScene::render(fggl::gfx::Graphics &gfx) {
 		fggl::gfx::Paint paint;
diff --git a/demo/demo/rollball.cpp b/demo/demo/rollball.cpp
index 3ef9ec5e3f12a7fbd650c178aa45df8b9b1a0b22..75439bb41c6e8ad8b20f2ff76703b08873c2cc90 100644
--- a/demo/demo/rollball.cpp
+++ b/demo/demo/rollball.cpp
@@ -182,12 +182,10 @@ namespace demo {
 		return force;
 	}
 
-	void RollBall::update() {
-		Game::update();
+	void RollBall::update(float deltaTime) {
+		Game::update(deltaTime);
 		m_phys->step();
 
-		const float deltaTime = 1 / 60.0F;
-
 		auto& input = this->input();
 
 			if ( state.player != fggl::entity::INVALID ) {
diff --git a/demo/demo/topdown.cpp b/demo/demo/topdown.cpp
index dfc9609eb9bed49b11734052ad6ef375d6945829..c94ca175535ea312ff90e99919eaa77d6990a8cc 100644
--- a/demo/demo/topdown.cpp
+++ b/demo/demo/topdown.cpp
@@ -127,8 +127,8 @@ void TopDown::activate() {
 	populate_sample_level(factory, world());
 }
 
-void TopDown::update() {
-	Game::update();
+void TopDown::update(float dt) {
+	Game::update(dt);
 
 	process_camera(world(), input());
 	if ( input().mouse.pressed(fggl::input::MouseButton::LEFT) ) {
diff --git a/demo/include/GameScene.h b/demo/include/GameScene.h
index 84ab5e6360ddab877cc02503f8806a9c4e2ecd50..96232caa1f4aed53dfbb724a596f1dab23f816e5 100644
--- a/demo/include/GameScene.h
+++ b/demo/include/GameScene.h
@@ -57,7 +57,7 @@ enum camera_type { cam_free, cam_arcball };
 			setup();
 		}
 
-		void update() override;
+		void update(float dt) override;
 		void render(fggl::gfx::Graphics& gfx) override;
 
 	private:
diff --git a/demo/include/grid.hpp b/demo/include/grid.hpp
index c0fd98d9f353d90e830b579c580da8756f0a04cf..380e7722a6a3da90e01143cde6fc030535168986 100644
--- a/demo/include/grid.hpp
+++ b/demo/include/grid.hpp
@@ -22,6 +22,7 @@
 #include <memory>
 
 #include "fggl/scenes/game.hpp"
+#include "fggl/animation/animator.hpp"
 #include "fggl/entity/gridworld/zone.hpp"
 
 namespace demo {
@@ -47,12 +48,15 @@ namespace demo {
 			void activate() override;
 			void deactivate() override;
 
-			//void update() override;
+			void update(float dt) override;
 			void render(fggl::gfx::Graphics& gfx) override;
 		private:
 			fggl::entity::grid::TileSet m_tiles;
+			fggl::animation::FrameAnimator m_animator;
 			std::unique_ptr<DemoGrid> m_grid;
 
+			void tickPlayer();
+
 	};
 
 }
diff --git a/demo/include/rollball.hpp b/demo/include/rollball.hpp
index 1b39f0363e42d9a956682c047bfebd9807cb64e5..fc23e5cb892d1a02e7eb8cbe3e630ff26d19e403 100644
--- a/demo/include/rollball.hpp
+++ b/demo/include/rollball.hpp
@@ -57,7 +57,7 @@ namespace demo {
 			void activate() override;
 			void deactivate() override;
 
-			void update() override;
+			void update(float dt) override;
 			void render(fggl::gfx::Graphics& gfx) override;
 
 		private:
diff --git a/demo/include/topdown.hpp b/demo/include/topdown.hpp
index af4642ff3268f25c2127b22a4ffdd6f48b5d4171..519dc900638af4bb6fbe04c9df25b528ac4199a7 100644
--- a/demo/include/topdown.hpp
+++ b/demo/include/topdown.hpp
@@ -29,7 +29,7 @@ namespace demo {
 			explicit TopDown(fggl::App& app);
 			void activate() override;
 
-			void update() override;
+			void update(float dt) override;
 			void render(fggl::gfx::Graphics& gfx) override;
 
 		private:
diff --git a/fggl/app.cpp b/fggl/app.cpp
index 59684c8432776ee51601b84608a76f09aadfc287..baaf2066cf4488b8c275dcee2c4f63b4adbecbf6 100644
--- a/fggl/app.cpp
+++ b/fggl/app.cpp
@@ -39,7 +39,13 @@ namespace fggl {
 			state.activate();
 		}
 
+		auto lastTime = glfwGetTime();
+
 		while (m_running) {
+			auto currTime = glfwGetTime();
+			auto delta = currTime - lastTime;
+			lastTime = currTime;
+
 			// trigger a state change if expected
 			if (m_expectedScene != m_states.activeID()) {
 				auto result = m_states.change(m_expectedScene);
@@ -55,7 +61,7 @@ namespace fggl {
 			//m_modules->onUpdate();
 
 			auto &state = m_states.active();
-			state.update();
+			state.update((float)delta);
 
 			// window rendering to frame buffer
 			if (m_window != nullptr) {
diff --git a/fggl/scenes/game.cpp b/fggl/scenes/game.cpp
index db795a3f148e0ea950413984163181ec2fb28206..14a2139b475e8e14d474e99ea4493eeb45e10be9 100644
--- a/fggl/scenes/game.cpp
+++ b/fggl/scenes/game.cpp
@@ -28,7 +28,7 @@ namespace fggl::scenes {
 		m_input = app.service<input::Input>();
 	}
 
-	void GameBase::update() {
+	void GameBase::update(float dt) {
 		// detect the user quitting
 		if (m_input != nullptr) {
 			bool escapePressed = m_input->keyboard.pressed(glfwGetKeyScancode(GLFW_KEY_ESCAPE));
@@ -59,7 +59,7 @@ namespace fggl::scenes {
 		m_world.reset();
 	}
 
-	void Game::update() {
+	void Game::update(float dt) {
 		assert(m_world && "called game update, but there was no world - was activate called?");
 
 		if (m_input != nullptr) {
diff --git a/fggl/scenes/menu.cpp b/fggl/scenes/menu.cpp
index de6dfc0d6cc8ea1178d194853d9135c4ee457ee6..2c38180e524c84fe6618d87cb55c23423170fd4e 100644
--- a/fggl/scenes/menu.cpp
+++ b/fggl/scenes/menu.cpp
@@ -26,7 +26,7 @@ namespace fggl::scenes {
 		m_inputs = app.service<input::Input>();
 	}
 
-	void BasicMenu::update() {
+	void BasicMenu::update(float dt) {
 		if (m_inputs != nullptr) {
 			m_cursorPos.x = m_inputs->mouse.axis(MouseAxis::X);
 			m_cursorPos.y = m_inputs->mouse.axis(MouseAxis::Y);
diff --git a/include/fggl/animation/animator.hpp b/include/fggl/animation/animator.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..2a5170d5b32eafebbee7a8c7f795a74e3d3ff1c1
--- /dev/null
+++ b/include/fggl/animation/animator.hpp
@@ -0,0 +1,87 @@
+/*
+ * This file is part of FGGL.
+ *
+ * FGGL 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.
+ *
+ * FGGL 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 FGGL.
+ * If not, see <https://www.gnu.org/licenses/>.
+ */
+
+//
+// Created by webpigeon on 03/09/22.
+//
+
+#ifndef FGGL_ANIMATION_ANIMATOR_H
+#define FGGL_ANIMATION_ANIMATOR_H
+
+#include <functional>
+#include <map>
+#include <cstdint>
+#include <cassert>
+
+namespace fggl::animation {
+
+	using AnimationCallback = std::function<void(void)>;
+	using CallbackHandle = uint32_t;
+
+	/**
+	 * Frame-based animation.
+	 *
+	 * Tries to maintain a constant framerate for things that care about that.
+	 */
+	class FrameAnimator {
+		public:
+			explicit inline FrameAnimator(float targetFPS) : m_target( 1.0F / targetFPS) {
+				assert( 0 <= m_target );
+			}
+
+			inline void reset() {
+				m_current = 0;
+			}
+
+			inline void update(float dt) {
+				assert(0 <= dt);
+				m_current += dt;
+				while ( m_current >= m_target) {
+					tick();
+					m_current -= m_target;
+				}
+				assert(0 <= m_current);
+			}
+
+			// tick the animation system, should be handled by update
+			inline void tick() {
+				for (auto& [k,v] : m_callbacks) {
+					v();
+				}
+			}
+
+			inline CallbackHandle add(AnimationCallback callback) {
+				auto myHandle = m_lastCallback++;
+				m_callbacks[myHandle] = callback;
+				return myHandle;
+			}
+
+			inline void remove(CallbackHandle handle) {
+				auto itr = m_callbacks.find(handle);
+				if ( itr != m_callbacks.end() ) {
+					m_callbacks.erase(itr);
+				}
+			}
+
+		private:
+			const float m_target;
+			float m_current = 0.0F;
+
+			CallbackHandle m_lastCallback = 0;
+			std::map<CallbackHandle, AnimationCallback> m_callbacks;
+	};
+
+} // namespace fggl::animation
+
+#endif //FGGL_ANIMATION_ANIMATOR_H
diff --git a/include/fggl/app.hpp b/include/fggl/app.hpp
index 13019167c54e170cead9995396eed5a6a814da31..98eab804e7aa6b84f2ecb45d882684ab1673339c 100644
--- a/include/fggl/app.hpp
+++ b/include/fggl/app.hpp
@@ -58,7 +58,7 @@ namespace fggl {
 			 * multiple updates per render or vice-versa depending on requriements. Update is intended for
 			 * dispatching game-system related infomation.
 			 */
-			virtual void update() = 0;
+			virtual void update(float dt) = 0;
 
 			/**
 			 * Perform actions neccerary for rendering the scene.
diff --git a/include/fggl/scenes/game.hpp b/include/fggl/scenes/game.hpp
index d9f7155f2db4d60557084557835fc03bd9aa616a..71a129f32115f9b188c011e5d4402afbf13064e9 100644
--- a/include/fggl/scenes/game.hpp
+++ b/include/fggl/scenes/game.hpp
@@ -30,7 +30,7 @@ namespace fggl::scenes {
 		public:
 			explicit GameBase(fggl::App &app);
 
-			void update() override;
+			void update(float dt) override;
 			void render(fggl::gfx::Graphics &gfx) override = 0;
 
 		protected:
@@ -52,7 +52,7 @@ namespace fggl::scenes {
 			void activate() override;
 			void deactivate() override;
 
-			void update() override;
+			void update(float dt) override;
 			void render(fggl::gfx::Graphics &gfx) override;
 
 		protected:
diff --git a/include/fggl/scenes/menu.hpp b/include/fggl/scenes/menu.hpp
index 5c6220c5aab446f352e7153ff7edf12b0ec97714..70c0b15e29d16a0e01087cfcd5d62907313dbb13 100644
--- a/include/fggl/scenes/menu.hpp
+++ b/include/fggl/scenes/menu.hpp
@@ -32,7 +32,7 @@ namespace fggl::scenes {
 		public:
 			explicit BasicMenu(App &owner);
 
-			void update() override;
+			void update(float dt) override;
 			void render(gfx::Graphics &paint) override;
 
 			void activate() override;