diff --git a/demo/CMakeLists.txt b/demo/CMakeLists.txt
index ab4c43517aa19b94a234b5a0dde417bace109e1a..bf34d0c9f2d10a0baca38e2916e29765d015da58 100644
--- a/demo/CMakeLists.txt
+++ b/demo/CMakeLists.txt
@@ -8,6 +8,7 @@ add_executable(demo
         demo/rollball.cpp
         demo/topdown.cpp
         demo/grid.cpp
+        demo/robot/programmer.cpp
 )
 
 
diff --git a/demo/demo/grid.cpp b/demo/demo/grid.cpp
index 00368b8ddfa026273b7caf8707d5f83fff325b4e..0099eef3742f62fc5f7f7376ee607c3603f60d7f 100644
--- a/demo/demo/grid.cpp
+++ b/demo/demo/grid.cpp
@@ -106,9 +106,11 @@ namespace demo {
 		std::function<void(void)> callback;
 	};
 
-	GridScene::GridScene(fggl::App &app) : GameBase(app), m_tiles(), m_animator(15.0F), m_grid(nullptr) {
+	GridScene::GridScene(fggl::App &app) : GameBase(app), m_tiles(), m_animator(15.0F), m_grid(nullptr), m_canvas() {
 		m_animator.add([this](){this->tickPlayer();});
 
+		auto btnGrid = std::make_unique<fggl::gui::GridBox>(0, 2);
+
 		std::array<Action, 4> actions{{
 										  {"<", [=]() { this->rotate(true); }},
 										  {">", [=]() { this->rotate(false); }},
@@ -122,20 +124,14 @@ namespace demo {
 			auto btn = std::make_unique<fggl::gui::Button>(pos, size);
 			btn->label(action.name);
 			btn->addCallback([=](){
-				this->m_program.m_instructions.push_back(action.callback);
+				this->m_program.m_instructions.push_back({action.name, action.callback});
 			});
-			m_canvas.add(std::move(btn));
-
-			if ( pos.x == 0 ) {
-				pos.x += 32 + 8;
-			} else {
-				pos.x = 0;
-				pos.y += 32 + 8;
-			}
+			btnGrid->add(std::move(btn));
 		}
 
+		// control buttons
 		{
-			fggl::math::vec2i size{32, 32};
+			fggl::math::vec2i size{64, 32};
 			auto btn = std::make_unique<fggl::gui::Button>(pos, size);
 			btn->label("go");
 			btn->addCallback([=](){
@@ -145,9 +141,32 @@ namespace demo {
 					this->m_program.playing = true;
 				}
 			});
-			m_canvas.add(std::move(btn));
+			btnGrid->add(std::move(btn));
+		}
+
+		{
+			fggl::math::vec2i size{64, 64};
+			auto btn = std::make_unique<fggl::gui::Button>(pos, size);
+			btn->label("Del");
+			btn->addCallback([=](){
+				if ( !this->m_program.playing ) {
+					if ( !m_program.m_instructions.empty() ) {
+						m_program.m_instructions.pop_back();
+					}
+				}
+			});
+			btnGrid->add(std::move(btn));
 		}
 
+		btnGrid->layout();
+		m_canvas.add(std::move(btnGrid));
+
+		// create a timeline panel
+		std::unique_ptr<robot::Timeline> timeline = std::make_unique<robot::Timeline>(m_program);
+		timeline->size({50,700}, {250, 250});
+		m_canvas.add(std::move(timeline));
+
+		m_canvas.layout();
 	}
 
 	void GridScene::activate() {
@@ -239,18 +258,9 @@ namespace demo {
 			fggl::math::rescale_ndc(cursorPos.x, 0, 1920.f),
 			fggl::math::rescale_ndc(cursorPos.y, 0, 1080.0f)
 		};
+		canvas.onMouseOver(projected);
 
-		auto *hoverWidget = canvas.getChildAt(projected);
-		/*if (hoverWidget != m_hover) {
-			if (m_hover != nullptr) {
-				m_hover->onExit();
-			}
-			m_hover = hoverWidget;
-			if (m_hover != nullptr) {
-				m_hover->onEnter();
-			}
-		}*/
-
+		// detect clicks
 		if (inputs.mouse.pressed(fggl::input::MouseButton::LEFT)) {
 			auto* widget = canvas.getChildAt(projected);
 			if (widget != nullptr) {
@@ -264,6 +274,7 @@ namespace demo {
 		GameBase::update(deltaTime);
 		m_animator.update(deltaTime);
 
+		m_canvas.update(deltaTime);
 		update_canvas(input(), m_canvas);
 	}
 
@@ -281,7 +292,7 @@ namespace demo {
 			}
 
 			robotState.power--;
-			m_program.m_instructions[ m_program.m_currInstruction ]();
+			m_program.m_instructions[ m_program.m_currInstruction ].m_func();
 			m_program.m_currInstruction++;
 		} else {
 			m_program.playing = false;
diff --git a/demo/demo/robot/programmer.cpp b/demo/demo/robot/programmer.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..6bdadcdbaf8749204ff3f5a194f01742ff53550f
--- /dev/null
+++ b/demo/demo/robot/programmer.cpp
@@ -0,0 +1,82 @@
+/*
+ * 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 04/09/22.
+//
+
+#include "robot/programmer.hpp"
+
+namespace demo::robot {
+
+	Timeline::Timeline(Program& program) {
+		m_tracks.push_back(std::ref(program));
+	}
+
+	void Timeline::update(float deltaTime) {
+		auto currSize = size();
+
+		float trackHeight = m_tracks.size() * 32.0F;
+		std::size_t widestTrack = 0;
+		for ( auto& track : m_tracks ) {
+			widestTrack = std::max(widestTrack, track.get().m_instructions.size());
+		}
+
+		float instructionWidth = 32 * widestTrack;
+		if ( currSize.x < instructionWidth || currSize.y < trackHeight ) {
+			size( topLeft(), {instructionWidth, trackHeight} );
+		}
+	}
+
+	void Timeline::render(fggl::gfx::Paint &paint) {
+		fggl::gui::Panel::render(paint);
+
+		renderInstructions(paint);
+	}
+
+	void Timeline::renderInstructions(fggl::gfx::Paint& paint) {
+		const auto size = this->size();
+		const fggl::math::vec2f barExtents{16, 16};
+
+		for ( auto track=0U; track < m_tracks.size(); ++track) {
+			auto& trackRef = m_tracks[track].get();
+
+			for (auto i = 0U; i < trackRef.m_instructions.size(); ++i) {
+				auto barCenter = this->topLeft();
+
+				barCenter.x += (i * (barExtents.x * 2) ) + barExtents.x;
+				barCenter.y += (track * barExtents.y * 2) + barExtents.y;
+
+				// bar background
+				auto colour = fggl::gfx::colours::LIGHT_GRAY;
+				auto textColour = fggl::gfx::colours::DARK_SLATE_GRAY;
+				if (i % 2 == 0) {
+					colour = fggl::gfx::colours::WHITE;
+				}
+
+				if (i == trackRef.m_currInstruction && trackRef.playing) {
+					colour = fggl::gfx::colours::MIDNIGHT_BLUE;
+					textColour = fggl::gfx::colours::LIGHT_GRAY;
+				}
+				auto rect = fggl::gfx::make_rect(barCenter, barExtents, colour);
+				paint.fill(rect);
+
+				// bar instruction
+				auto& instruction = trackRef.m_instructions[i];
+				paint.text(instruction.name, barCenter, textColour);
+			}
+		}
+	}
+
+}
diff --git a/demo/include/grid.hpp b/demo/include/grid.hpp
index be9561a323a6fe596d2c627956f5e2e592f4f62e..158c56d85f6c97ae4cedbf34863a06bf2d48f1a9 100644
--- a/demo/include/grid.hpp
+++ b/demo/include/grid.hpp
@@ -26,6 +26,7 @@
 
 #include "fggl/animation/animator.hpp"
 #include "fggl/gui/gui.hpp"
+#include "robot/programmer.hpp"
 
 namespace demo {
 
@@ -46,12 +47,6 @@ namespace demo {
 		float rotationOffset{0.0F};
 	};
 
-	struct Program {
-		std::vector<std::function<void(void)>> m_instructions;
-		uint32_t m_currInstruction;
-		bool playing = false;
-	};
-
 	struct RobotState {
 		uint32_t power = 64;
 	};
@@ -65,14 +60,19 @@ namespace demo {
 			void update(float dt) override;
 			void render(fggl::gfx::Graphics& gfx) override;
 		private:
+
+			// level
+			LevelRules m_levelRules;
 			fggl::entity::grid::TileSet m_tiles;
-			fggl::animation::FrameAnimator m_animator;
 			std::unique_ptr<DemoGrid> m_grid;
-			fggl::gui::Container m_canvas;
-			LevelRules m_levelRules;
 
+			// control
 			fggl::entity::EntityID m_player = fggl::entity::INVALID;
-			Program m_program;
+			robot::Program m_program;
+
+			// UI
+			fggl::gui::Container m_canvas;
+			fggl::animation::FrameAnimator m_animator;
 
 			void resetPuzzle();
 
diff --git a/demo/include/robot/programmer.hpp b/demo/include/robot/programmer.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..1025c182972176d46bf948ea48c0832a187e9af3
--- /dev/null
+++ b/demo/include/robot/programmer.hpp
@@ -0,0 +1,55 @@
+/*
+ * 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 04/09/22.
+//
+
+#ifndef FGGL_DEMO_INCLUDE_ROBOT_PROGRAMMER_HPP
+#define FGGL_DEMO_INCLUDE_ROBOT_PROGRAMMER_HPP
+
+#include <functional>
+
+#include "fggl/gui/containers.hpp"
+
+namespace demo::robot {
+
+	struct Instruction {
+		const char* name;
+		std::function<void(void)> m_func;
+	};
+
+	struct Program {
+		std::vector<Instruction> m_instructions;
+		uint32_t m_currInstruction;
+		bool playing = false;
+	};
+
+	class Timeline : public fggl::gui::Panel {
+		public:
+			explicit Timeline(Program& program);
+
+			void update(float deltaTime) override;
+			void render(fggl::gfx::Paint& paint) override;
+
+		protected:
+			void renderInstructions(fggl::gfx::Paint& paint);
+
+		private:
+			std::vector<std::reference_wrapper<Program>> m_tracks;
+	};
+
+}
+
+#endif //FGGL_DEMO_INCLUDE_ROBOT_PROGRAMMER_HPP
diff --git a/fggl/gfx/ogl4/canvas.cpp b/fggl/gfx/ogl4/canvas.cpp
index 42b20bc3dc99a92cc16a55159c8431f98dba595d..750e884ce58989d115190736d862c0d70cf0a9da 100644
--- a/fggl/gfx/ogl4/canvas.cpp
+++ b/fggl/gfx/ogl4/canvas.cpp
@@ -200,20 +200,18 @@ namespace fggl::gfx::ogl4 {
 
 				// this is why this is called the slow version, we render each quad as a single call
 				auto &metrics = face->metrics(letter);
-				float xPos = penPos.x + metrics.bearing.x;
-				float yPos = (penPos.y - metrics.bearing.y);
-				float w = metrics.size.x;
-				float h = metrics.size.y;
-
-				const math::vec3 texCol{1.0f, 1.0f, 1.0f};
+				const float xPos = penPos.x + metrics.bearing.x;
+				const float yPos = (penPos.y - metrics.bearing.y);
+				const float w = metrics.size.x;
+				const float h = metrics.size.y;
 
 				std::array<data::Vertex2D, 6> verts{{
-														{{xPos, yPos + h}, texCol, {0.0F, 1.0F}},
-														{{xPos, yPos}, texCol, {0.0F, 0.0F}},
-														{{xPos + w, yPos}, texCol, {1.0F, 0.0F}},
-														{{xPos, yPos + h}, texCol, {0.0F, 1.0F}},
-														{{xPos + w, yPos}, texCol, {1.0F, 0.0F}},
-														{{xPos + w, yPos + h}, texCol, {1.0F, 1.0F}},
+														{{xPos, yPos + h}, textCmd.colour, {0.0F, 1.0F}},
+														{{xPos, yPos}, textCmd.colour, {0.0F, 0.0F}},
+														{{xPos + w, yPos}, textCmd.colour, {1.0F, 0.0F}},
+														{{xPos, yPos + h}, textCmd.colour, {0.0F, 1.0F}},
+														{{xPos + w, yPos}, textCmd.colour, {1.0F, 0.0F}},
+														{{xPos + w, yPos + h}, textCmd.colour, {1.0F, 1.0F}},
 													}};
 				m_vertexList.replace(verts.size(), verts.data());
 
diff --git a/fggl/gui/containers.cpp b/fggl/gui/containers.cpp
index c0232ddbc698dc33452f5ca5993ca696b0e63600..089f90ed9e23d0e9d39f8161812e8f818941b456 100644
--- a/fggl/gui/containers.cpp
+++ b/fggl/gui/containers.cpp
@@ -42,6 +42,81 @@ namespace fggl::gui {
 		m_dirty = true;
 	}
 
+	void Container::onMouseOver(math::vec2 pos) {
+		Widget::onMouseOver(pos);
+		auto* childHover = getChildAt(pos);
+
+		for ( auto& child : m_children) {
+			if ( child.get() != childHover ) {
+				child->onExit(pos);
+			}
+		}
+
+		if ( childHover != nullptr ) {
+			childHover->onMouseOver(pos);
+		}
+	}
+
+	void Container::onEnter(math::vec2i pos) {
+		Widget::onEnter(pos);
+		for ( auto& child : m_children ) {
+			if ( child->contains(pos)) {
+				child->onEnter(pos);
+			}
+		}
+	}
+
+	void Container::onExit(math::vec2i pos) {
+		Widget::onExit(pos);
+		for ( auto& child : m_children) {
+			child->onExit(pos);
+		}
+	}
+
+	GridBox::GridBox(uint32_t rows, uint32_t cols, uint32_t padx, uint32_t pady) : m_rows(rows), m_cols(cols), m_padding(padx, pady) {}
+
+	void GridBox::layout() {
+		assert( m_rows != 0 || m_cols != 0 );
+
+		if ( m_rows == 0 ) {
+			int rows = m_children.size() / m_cols;
+
+			// figure out the width and heights
+			float* widths = new float[m_cols]{0.0F};
+			float* heights = new float[rows]{0.0F};
+			for ( auto idx = 0U; idx < m_children.size(); ++idx) {
+				auto& child = m_children[idx];
+				int col = idx % m_cols;
+				int row = idx / m_cols;
+
+				widths[col] = std::max( child->size().x, widths[col] );
+				heights[row] = std::max( child->size().y, heights[row] );
+			}
+
+			// populate the grid
+			fggl::math::vec2i pos{0, 0};
+			int row = 0;
+			int col = 0;
+			for ( auto& child : m_children ) {
+				fggl::math::vec2i size{ widths[col], heights[row] };
+				child->size(pos, size);
+				child->layout();
+
+				// next iter
+				pos.x += size.x + m_padding.x;
+				col++;
+				if ( col == m_cols ) {
+					col = 0;
+					row++;
+					pos.x = 0;
+					pos.y += size.y + m_padding.y;
+				}
+			}
+
+		}
+	}
+
+
 	/*
 	Box::Box( LayoutAxis axis ) : m_axis( axis ) {}
 	
diff --git a/fggl/gui/widgets.cpp b/fggl/gui/widgets.cpp
index a35a8fa0c123f52589eeb5114070b9eea93c93f5..967e85730f64ae8d9385b23c40b2f68a511f539f 100644
--- a/fggl/gui/widgets.cpp
+++ b/fggl/gui/widgets.cpp
@@ -23,8 +23,7 @@
 
 namespace fggl::gui {
 
-	Button::Button(math::vec2 pos, math::vec2 size) : Widget(pos, size), m_label(pos, size), m_hover(false),
-													  m_active(false) {}
+	Button::Button(math::vec2 pos, math::vec2 size) : Widget(pos, size), m_label(pos, size), m_active(false) {}
 
 	void Button::render(gfx::Paint &paint) {
 		gfx::Path2D path{topLeft()};
@@ -44,14 +43,6 @@ namespace fggl::gui {
 		}
 	}
 
-	void Button::onEnter() {
-		m_hover = true;
-	}
-
-	void Button::onExit() {
-		m_hover = false;
-	}
-
 	void Button::addCallback(Callback cb) {
 		m_callbacks.push_back(cb);
 	}
@@ -60,6 +51,10 @@ namespace fggl::gui {
 		m_label.text(value);
 	}
 
+	void Button::layout() {
+		m_label.size(topLeft(), size());
+	}
+
 	std::string Button::label() const {
 		return m_label.text();
 	}
diff --git a/fggl/scenes/menu.cpp b/fggl/scenes/menu.cpp
index 1c0a64d6926f7a4e66d4ffb72786936574d65fac..36639147a33f34ae2233fcbf982087d3a9fc278b 100644
--- a/fggl/scenes/menu.cpp
+++ b/fggl/scenes/menu.cpp
@@ -34,27 +34,13 @@ namespace fggl::scenes {
 			// in canvas space
 			math::vec2 projected;
 			projected.x = math::rescale_ndc(m_cursorPos.x, 0, 1920.f);
-			//projected.y = math::rescale_ndc(m_cursorPos.y, 1080.0f, 0);
 			projected.y = math::rescale_ndc(m_cursorPos.y, 0, 1080.0f);
-
-			auto *hoverWidget = m_canvas.getChildAt(projected);
-			if (hoverWidget != m_hover) {
-				if (m_hover != nullptr) {
-					m_hover->onExit();
-				}
-				m_hover = hoverWidget;
-				if (m_hover != nullptr) {
-					m_hover->onEnter();
-				}
-			}
+			m_canvas.onMouseOver(projected);
 
 			if (m_inputs->mouse.pressed(MouseButton::LEFT)) {
-				spdlog::info("clicky clicky: ({}, {})", projected.x, projected.y);
-
-				auto widget = m_canvas.getChildAt(projected);
+				auto* widget = m_canvas.getChildAt(projected);
 				if (widget != nullptr) {
 					widget->activate();
-					spdlog::info("ooo! there is a thing there!");
 				}
 			}
 		}
diff --git a/include/fggl/gfx/paint.hpp b/include/fggl/gfx/paint.hpp
index 7985467cd9f99a06d8ff5a411339cdc2b196ccbd..fe18e27c40b96976789f8126d339f6e1ff199515 100644
--- a/include/fggl/gfx/paint.hpp
+++ b/include/fggl/gfx/paint.hpp
@@ -284,6 +284,7 @@ namespace fggl::gfx {
 	struct TextCmd {
 		const std::string text;
 		const math::vec2 pos;
+		const math::vec3 colour;
 	};
 
 	class Paint {
@@ -299,8 +300,8 @@ namespace fggl::gfx {
 				m_cmds.push_back({PaintType::STROKE, path});
 			}
 
-			void text(const std::string &text, const math::vec2 &pos) {
-				m_text.push_back({text, pos});
+			void text(const std::string &text, const math::vec2 &pos, const math::vec3f colour = fggl::gfx::colours::BLACK) {
+				m_text.push_back({text, pos, colour});
 			}
 
 			const std::vector<PaintCmd> &cmds() const {
diff --git a/include/fggl/gui/containers.hpp b/include/fggl/gui/containers.hpp
index 005f0bff4ec980255a2380dcafabddb18581b0aa..a0ab0607ab4d02dd18dd296ca7d51afd02657752 100644
--- a/include/fggl/gui/containers.hpp
+++ b/include/fggl/gui/containers.hpp
@@ -34,15 +34,37 @@ namespace fggl::gui {
 
 			bool contains(const math::vec2 &point) override;
 			Widget *getChildAt(const math::vec2 &point) override;
+
+			inline void update(float deltaTime) override {
+				for (auto& child : m_children) {
+					child->update(deltaTime);
+				}
+			}
 			void render(gfx::Paint &paint) override;
 
+			void onMouseOver(math::vec2 pos) override;
+
+			void onEnter(math::vec2i pos) override;
+			void onExit(math::vec2i pos) override;
+
 		private:
 			bool m_dirty;
+			Widget* m_hovered = nullptr;
 
 		protected:
 			std::vector<std::unique_ptr<Widget>> m_children;
 	};
 
+	class GridBox : public Container {
+		public:
+			GridBox(uint32_t rows, uint32_t cols, uint32_t padX = 8, uint32_t padY = 8);
+			void layout() override;
+		private:
+			uint32_t m_rows;
+			uint32_t m_cols;
+			math::vec2i m_padding;
+	};
+
 	class Panel : public Container {
 		public:
 			Panel() = default;
diff --git a/include/fggl/gui/widget.hpp b/include/fggl/gui/widget.hpp
index 9ef5147c2fcb6489c62ee2f7e2fb7456c26dffad..8cc131c376a492afa2876f336a7ae469e7d4700e 100644
--- a/include/fggl/gui/widget.hpp
+++ b/include/fggl/gui/widget.hpp
@@ -126,13 +126,27 @@ namespace fggl::gui {
 				return this;
 			}
 
+			virtual void update(float deltaTime) = 0;
 			virtual void render(gfx::Paint &paint) = 0;
 
+			inline virtual void layout() {};
+
 			inline virtual void activate() {};
 
-			inline virtual void onEnter() {}
+			inline virtual void onMouseOver(math::vec2 pos) {
+				if ( !m_hover ) {
+					onEnter(pos);
+				}
+			}
+			inline virtual void onEnter(math::vec2i /*pos*/) {
+				m_hover = true;
+			}
+			inline virtual void onExit(math::vec2i /*pos*/) {
+				m_hover = false;
+			}
 
-			inline virtual void onExit() {}
+		protected:
+			bool m_hover = false;
 
 		private:
 			Bounds2D m_bounds;
diff --git a/include/fggl/gui/widgets.hpp b/include/fggl/gui/widgets.hpp
index ac42a16993c04c2f94e76bb2a129cff12942228f..9a206f9a61e64f3ae8f157ddff84f8cbc7a96ca5 100644
--- a/include/fggl/gui/widgets.hpp
+++ b/include/fggl/gui/widgets.hpp
@@ -60,7 +60,8 @@ namespace fggl::gui {
 				return m_naturalSize;
 			}
 
-			void layout();
+			void layout() override;
+			inline void update(float deltaTime) override {}
 
 		private:
 			std::shared_ptr<gui::FontFace> m_font;
@@ -77,20 +78,20 @@ namespace fggl::gui {
 			void render(gfx::Paint &paint) override;
 
 			void activate() override;
-			void onEnter() override;
-			void onExit() override;
-
 			void label(const std::string &value);
 
 			[[nodiscard]]
 			std::string label() const;
 
+			inline void update(float deltaTime) override {}
+
 			void addCallback(Callback callback);
+			void layout() override;
+
 		private:
 			Label m_label;
 			std::string m_value;
 			std::vector<Callback> m_callbacks;
-			bool m_hover;
 			bool m_active;
 	};