diff --git a/demo/CMakeLists.txt b/demo/CMakeLists.txt
index 83349bf9649ad8b96b79be2074fd9318cafd7a1e..ab4c43517aa19b94a234b5a0dde417bace109e1a 100644
--- a/demo/CMakeLists.txt
+++ b/demo/CMakeLists.txt
@@ -7,7 +7,8 @@ add_executable(demo
         demo/GameScene.cpp
         demo/rollball.cpp
         demo/topdown.cpp
-        )
+        demo/grid.cpp
+)
 
 
 target_include_directories(demo
diff --git a/demo/demo/grid.cpp b/demo/demo/grid.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..7f7d99b24efd6047f31b021934761fcfefbd0ac1
--- /dev/null
+++ b/demo/demo/grid.cpp
@@ -0,0 +1,73 @@
+/*
+ * 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 27/08/22.
+//
+
+#include "grid.hpp"
+#include "fggl/entity/gridworld/zone.hpp"
+
+namespace demo {
+
+	GridScene::GridScene(fggl::App &app) : Game(app), m_grid(nullptr) {
+	}
+
+	void GridScene::activate() {
+		Game::activate();
+		fggl::debug::log(fggl::debug::Level::info, "GridScene::activate()");
+
+		// create the grid world
+		m_grid = std::make_unique<fggl::entity::grid::Area2D<255,255>>(m_tiles);
+	}
+
+	void GridScene::deactivate() {
+		m_grid = nullptr;
+	}
+
+
+	constexpr float DRAW_SIZE = 64.0F;
+	constexpr float DRAW_HALF = DRAW_SIZE / 2.0F;
+
+	static void render_grid(fggl::gfx::Paint& paint, fggl::entity::grid::Area2D<255, 255>& grid) {
+		for (int i=0; i <= 31; ++i) {
+			for (int j=0; j <= 31; ++j) {
+				auto& cell = grid.floorAt(i, j);
+
+				float x = i * DRAW_SIZE;
+				float y = j * DRAW_SIZE;
+				auto colour = (i + j) % 2 == 0 ? fggl::gfx::colours::WHITE : fggl::gfx::colours::BLACK;
+
+				fggl::gfx::Path2D tileGfx = fggl::gfx::make_rect({x,y}, {DRAW_HALF, DRAW_HALF}, colour);
+				paint.fill(tileGfx);
+			}
+		}
+	}
+
+	void GridScene::render(fggl::gfx::Graphics &gfx) {
+		Game::render(gfx);
+
+		fggl::gfx::Paint paint;
+		render_grid(paint, *m_grid);
+
+		// draw test shapes to check grid alignment
+		for (int sides = 3; sides <= 25; ++sides) {
+			auto hexTest = fggl::gfx::make_shape(fggl::math::vec2{sides, 5} * DRAW_SIZE, DRAW_HALF, sides, {1.0F - (sides / 25.0F), 0.0f, sides / 25.0f});
+			paint.fill(hexTest);
+		}
+
+		gfx.draw2D(paint);
+	}
+
+} // namespace demo
diff --git a/demo/demo/main.cpp b/demo/demo/main.cpp
index 3a0dc6279e8534327d63e4e58f5382532deadd25..9425d8aff808bd1a8c1cf770971843cb7b8b8441 100644
--- a/demo/demo/main.cpp
+++ b/demo/demo/main.cpp
@@ -45,28 +45,24 @@
 #include "GameScene.h"
 #include "rollball.hpp"
 #include "topdown.hpp"
+#include "grid.hpp"
 
 static void setup_menu(fggl::App& app) {
 	auto *menu = app.addState<fggl::scenes::BasicMenu>("menu");
 
 	// add some menu items for the game states
-	menu->add("terrain", [&app]() {
-		auto* audio = app.service<fggl::audio::AudioService>();
-		audio->play("click.ogg", false);
-		app.change_state("game");
-	});
+	const std::array labels = {"terrain", "rollball", "Top Down", "Grid World"};
+	const std::array scenes = {"game", "rollball", "topdown", "gridworld"};
 
-	menu->add("rollball", [&app]() {
-		auto* audio = app.service<fggl::audio::AudioService>();
-		audio->play("click.ogg", false);
-		app.change_state("rollball");
-	});
+	for (std::size_t i = 0; i < labels.size(); ++i) {
+		std::string sceneName = scenes.at(i);
 
-	menu->add("Top Down", [&app]() {
-		auto* audio = app.service<fggl::audio::AudioService>();
-		audio->play("click.ogg", false);
-		app.change_state("topdown");
-	});
+		menu->add(labels.at(i), [&app, sceneName]() {
+			auto* audio = app.service<fggl::audio::AudioService>();
+			audio->play("click.ogg", false);
+			app.change_state(sceneName);
+		});
+	}
 
 	menu->add("quit", [&app]() {
 		auto* audio = app.service<fggl::audio::AudioService>();
@@ -117,6 +113,7 @@ int main(int argc, const char* argv[]) {
     app.addState<GameScene>("game");
 	app.addState<demo::RollBall>("rollball");
 	app.addState<demo::TopDown>("topdown");
+	app.addState<demo::GridScene>("gridworld");
 
 	return app.run(argc, argv);
 }
diff --git a/demo/include/grid.hpp b/demo/include/grid.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..6c089b587d61e5a206ec53f3f085bdb81fe065ea
--- /dev/null
+++ b/demo/include/grid.hpp
@@ -0,0 +1,47 @@
+/*
+ * 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 27/08/22.
+//
+
+#ifndef FGGL_DEMO_INCLUDE_GRID_HPP
+#define FGGL_DEMO_INCLUDE_GRID_HPP
+
+#include <memory>
+
+#include "fggl/scenes/game.hpp"
+#include "fggl/entity/gridworld/zone.hpp"
+
+namespace demo {
+
+	constexpr int GRID_SIZE = 255;
+
+	class GridScene : public fggl::scenes::Game {
+		public:
+			explicit GridScene(fggl::App& app);
+			void activate() override;
+			void deactivate() override;
+
+			//void update() override;
+			void render(fggl::gfx::Graphics& gfx) override;
+		private:
+			fggl::entity::grid::TileSet m_tiles;
+			std::unique_ptr<fggl::entity::grid::Area2D<GRID_SIZE,GRID_SIZE>> m_grid;
+
+	};
+
+}
+
+#endif //FGGL_DEMO_INCLUDE_GRID_HPP
diff --git a/include/fggl/entity/gridworld/zone.hpp b/include/fggl/entity/gridworld/zone.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..720b845bc119afc2f260ecabb74824a3ef82aef7
--- /dev/null
+++ b/include/fggl/entity/gridworld/zone.hpp
@@ -0,0 +1,107 @@
+/*
+ * 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 20/08/22.
+//
+
+#ifndef FGGL_ENTITY_GRIDWORLD_ZONE_HPP
+#define FGGL_ENTITY_GRIDWORLD_ZONE_HPP
+
+#include <array>
+#include <vector>
+
+#include "fggl/math/types.hpp"
+
+namespace fggl::entity::grid {
+
+	template<typename T, uint32_t width, uint32_t height>
+	struct Grid {
+		public:
+			Grid() = default;
+
+			inline T& at(math::vec2i pos) {
+				return m_cells[getCellIndex(pos)];
+			}
+
+			const T& at(math::vec2i pos) const {
+				return m_cells[getCellIndex(pos)];
+			}
+
+			inline bool inBounds(math::vec2i pos) const {
+				return 0 <= pos.x && pos.x <= size.x &&
+					   0 <= pos.y && pos.y <= size.y;
+			}
+
+		private:
+			constexpr static math::vec2i size = math::vec2i(width, height);
+			std::array<T, size.x * size.y> m_cells;
+
+
+			inline uint32_t getCellIndex(math::vec2i pos) const {
+				assert( inBounds(pos));
+				return pos.y * size.x + pos.x;
+			}
+	};
+
+	struct FloorTile {
+		constexpr static uint8_t IMPOSSIBLE = 0;
+		uint8_t moveCost = IMPOSSIBLE;
+	};
+
+	struct TileSet {
+		std::vector<FloorTile> m_floors;
+	};
+
+	/**
+	 * A 2D representation of a space.
+	 *
+	 * @tparam width the grid width in units
+	 * @tparam height the grid height in units
+	 */
+	template<uint32_t width, uint32_t height>
+	struct Area2D {
+		public:
+			inline explicit Area2D(TileSet& tiles) : m_tiles(tiles) {}
+
+			inline FloorTile& floorAt(uint32_t x, uint32_t y) {
+				return m_tiles.m_floors[ m_floors.at({x, y}) ];
+			}
+
+			inline bool canMove(math::vec2i pos) const {
+				return m_tiles.m_floors[m_floors.getCell(pos)] != 0;
+			}
+
+			inline bool canMove(math::vec2i pos, math::vec2i dir) const {
+				return canMove(pos + dir) && !blocked(pos, dir);
+			}
+
+			inline bool blocked(math::vec2i pos, math::vec2i dir) const {
+				if ( dir.x == -1 || dir.y == -1 ) {
+					return m_walls.getCell(pos) != 0;
+				} else {
+					m_walls.getCell(pos + dir) != 0;
+				}
+			}
+
+			void neighbours(math::vec2i pos, std::vector<math::vec2i> &neighbours) const;
+		private:
+			TileSet& m_tiles;
+			Grid<uint32_t, width, height> m_floors;
+			Grid<uint32_t, width + 1, height + 1> m_walls;
+	};
+
+} // namespace fggl::entity::gridworld
+
+#endif //FGGL_ENTITY_GRIDWORLD_ZONE_HPP
diff --git a/include/fggl/gfx/paint.hpp b/include/fggl/gfx/paint.hpp
index 58d94c604bc8f6738ef229eb2c41471e210705ae..06d10ba493bdd9de5f884b8a3bfa2850ab7405b8 100644
--- a/include/fggl/gfx/paint.hpp
+++ b/include/fggl/gfx/paint.hpp
@@ -23,6 +23,20 @@ namespace fggl::gfx {
 
 	using RadianAngle = float;
 
+	namespace colours {
+		constexpr math::vec3 WHITE{1.0F, 1.0F, 1.0F};
+		constexpr math::vec3 BLACK{0.0F, 0.0F, 0.0F};
+
+		constexpr math::vec3 RED{1.0F, 0.0F, 0.0F};
+		constexpr math::vec3 GREEN{0.0F, 1.0F, 0.0F};
+		constexpr math::vec3 BLUE{0.0F, 0.0F, 1.0F};
+
+		constexpr math::vec3 YELLOW{1.0F, 1.0F, 0.0F};
+		constexpr math::vec3 CYAN{0.0F, 1.0F, 1.0F};
+		constexpr math::vec3 MAGENTA{1.0F, 0.0F, 1.0F};
+	}
+
+
 	enum class PathType {
 		MOVE,
 		PATH,
@@ -67,6 +81,44 @@ namespace fggl::gfx {
 		std::vector<math::vec3> m_colours;
 	};
 
+	inline Path2D make_rect(math::vec2 center, math::vec2 radius, math::vec3 colour = {1.0f, 1.0f, 1.0f}) {
+		fggl::gfx::Path2D tileGfx(center);
+		tileGfx.colour(colour);
+		tileGfx.moveTo(center - radius);
+		tileGfx.pathTo({center.x + radius.x, center.y - radius.y});
+		tileGfx.pathTo(center + radius);
+		tileGfx.pathTo({center.x - radius.x, center.y + radius.y});
+		tileGfx.close();
+		return tileGfx;
+	}
+
+	struct ShapeOpts {
+		float angleOffset = 0.0F;
+		bool sinFirst = true;
+	};
+
+	inline Path2D make_shape(math::vec2 center, float radius, int sides, math::vec3 colour = {1.0f, 1.0f, 1.0f}, ShapeOpts opts = {}) {
+		double angle = (M_PI * 2.0) / sides;
+
+		fggl::gfx::Path2D tileGfx(center);
+		tileGfx.colour(colour);
+
+		for (int i=0; i < sides; ++i) {
+			float xPos = (float)(sin(i * angle + opts.angleOffset) * radius) + center.x;
+			float yPos = (float)(cos(i * angle + opts.angleOffset) * radius) + center.y;
+			if (!opts.sinFirst) {
+				std::swap(xPos, yPos);
+			}
+			if ( i == 0 ) {
+				tileGfx.moveTo( {xPos, yPos} );
+			} else {
+				tileGfx.pathTo({xPos, yPos});
+			}
+		}
+		tileGfx.close();
+		return tileGfx;
+	}
+
 	enum class PaintType {
 		FILL,
 		STROKE