diff --git a/Cargo.lock b/Cargo.lock
index 8c1947b..0105d87 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -588,6 +588,25 @@ dependencies = [
  "glam",
 ]
 
+[[package]]
+name = "bevy_mod_picking"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db42ac84b1409452bbfa696d9071b9a7a2505c73620c939b758b5bf23573976a"
+dependencies = [
+ "bevy",
+ "bevy_mod_raycast",
+]
+
+[[package]]
+name = "bevy_mod_raycast"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aead49a20f5e694f4fb59c7312f9a1813b65a2a0ac2c385d53d40f25cae896f"
+dependencies = [
+ "bevy",
+]
+
 [[package]]
 name = "bevy_pbr"
 version = "0.8.1"
@@ -911,6 +930,7 @@ dependencies = [
  "bevy",
  "bevy-inspector-egui",
  "bevy_common_assets",
+ "bevy_mod_picking",
  "bevy_rapier2d",
  "cpal 0.14.0",
  "crossbeam-channel",
@@ -919,6 +939,7 @@ dependencies = [
  "rand_distr",
  "rapier2d",
  "serde",
+ "serde_json",
  "ticktock",
 ]
 
@@ -1563,9 +1584,9 @@ dependencies = [
 
 [[package]]
 name = "erased-serde"
-version = "0.3.22"
+version = "0.3.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "003000e712ad0f95857bd4d2ef8d1890069e06554101697d12050668b2f6f020"
+checksum = "54558e0ba96fbe24280072642eceb9d7d442e32c7ec0ea9e7ecd7b4ea2cf4e11"
 dependencies = [
  "serde",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index cb38147..8fb6f94 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,7 +8,6 @@ edition = "2021"
 [dependencies]
 bevy = "0.8.1"
 bevy_common_assets = { version = "0.3.0", features = ["json"] }
-bevy-inspector-egui = "0.12.1"
 bevy_rapier2d = "0.16.2"
 crossbeam-channel = "0.5.6"
 rand = "0.8.5"
@@ -17,8 +16,11 @@ rapier2d = "0.14.0"
 serde = { version = "1.0.144", features = ["derive"] }
 
 [target."cfg(not(target_arch = \"wasm32\"))".dependencies]
+bevy-inspector-egui = "0.12.1"
+bevy_mod_picking = "0.9.0"
 cpal = "0.14.0"
 hexodsp = { git = "https://github.com/WeirdConstructor/HexoDSP", default-features = false }
+serde_json = "1.0.85"
 ticktock = "0.8.0"
 
 [target."cfg(target_arch = \"wasm32\")".dependencies]
diff --git a/README.md b/README.md
index 17112e6..c452b76 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,19 @@ This game uses [HexoDSP](https://github.com/WeirdConstructor/HexoDSP) for audio
 
 The synthetizer matrix can be edited using [HexoSynth](https://github.com/WeirdConstructor/HexoSynth) visual editor.
 
+## Develop
+
+Skip to level `N: u32` with the command `bevyjam <N>`.
+
+Edit the level `N: u32` with the command `bevyjam <N> e`.
+
+### Editor controls
+
+* **Select**: left click to select, click in void to deselect, CTRL+click to select many, CTRL+A to select all
+* **Move selection**: arrows to move one step, Shift+arrows to move continuously
+* **Move camera**: CTRL+arrows
+* **Save**: CTRL+S
+
 ## License
 
 GNU AGPL v3, CopyLeft 2022 Pascal Engélibert, Nixon Cheng
diff --git a/assets/game.levels.json b/assets/game.levels.json
index 549c17b..bca10de 100644
--- a/assets/game.levels.json
+++ b/assets/game.levels.json
@@ -1,119 +1,346 @@
 {
-	"levels": [
-		{
-			"comment": "Movement tutorial",
-			"platforms": [
-				{"pos": [0, -256], "size": [800, 16]}
-			],
-			"characters": [
-				{"pos": [0, -192], "color": [1,0,0,1]},
-				{"pos": [-128, -192], "color": [0,1,0,1]},
-				{"pos": [128, -192], "color": [0,0,1,1]}
-			],
-			"absorbing_filters": [],
-			"rotating_filters": [],
-			"texts": [
-				{
-					"pos": [0, 0],
-					"font_size": 32,
-					"text": "Combine the colors to synthetize a white light.\nUse arrows to move."
-				}
-			]
-		},
-		{
-			"comment": "Switch tutorial",
-			"platforms": [
-				{"pos": [0, -256], "size": [800, 16]},
-				{"pos": [128, 256], "size": [96, 16]}
-			],
-			"characters": [
-				{"pos": [0, -192], "color": [0,1,0,1]},
-				{"pos": [-128, -192], "color": [1,0,0,1]},
-				{"pos": [128, 320], "color": [0,0,1,1]}
-			],
-			"absorbing_filters": [],
-			"rotating_filters": [],
-			"texts": [
-				{
-					"pos": [0, 0],
-					"font_size": 32,
-					"text": "Press Tab to switch."
-				}
-			]
-		},
-		{
-			"comment": "Absorbing filter tutorial",
-			"platforms": [
-				{"pos": [0, -256], "size": [800, 16]},
-				{"pos": [0, -128], "size": [800, 16]}
-			],
-			"characters": [
-				{"pos": [-128, -192], "color": [1,0.64,0,1]},
-				{"pos": [128, -192], "color": [0,0.37,1,1]}
-			],
-			"absorbing_filters": [
-				{
-					"pos": [0, -192],
-					"size": [16, 112],
-					"color": [1,0,0,1]
-				}
-			],
-			"rotating_filters": [],
-			"texts": [
-				{
-					"pos": [0, 0],
-					"font_size": 32,
-					"text": "Press R to reset."
-				}
-			]
-		},
-		{
-			"comment": "Rotating filter tutorial",
-			"platforms": [
-				{"pos": [0, -256], "size": [800, 16]}
-			],
-			"characters": [
-				{"pos": [0, -192], "color": [1,0,0,1]},
-				{"pos": [-128, -192], "color": [1,0,0,1]},
-				{"pos": [128, -192], "color": [1,0,0,1]}
-			],
-			"absorbing_filters": [],
-			"rotating_filters": [
-				{
-					"pos": [0, -64],
-					"angle": 45
-				}
-			],
-			"texts": [
-				{
-					"pos": [0, 0],
-					"font_size": 32,
-					"text": "Let's rotate the hue!"
-				}
-			]
-		},
-		{
-			"comment": "Game over",
-			"platforms": [
-				{"pos": [0, -256], "size": [800, 16]}
-			],
-			"characters": [
-				{"pos": [0, -64], "color": [1,0,0,1]}
-			],
-			"absorbing_filters": [],
-			"rotating_filters": [],
-			"texts": [
-				{
-					"pos": [0, 128],
-					"font_size": 48,
-					"text": "Thank you for playing!"
-				},
-				{
-					"pos": [0, 0],
-					"font_size": 32,
-					"text": "There is no more light to combine."
-				}
-			]
-		}
-	]
+  "levels": [
+    {
+      "comment": "Movement tutorial",
+      "characters": [
+        {
+          "pos": [
+            0.0,
+            -192.0
+          ],
+          "color": [
+            1.0,
+            0.0,
+            0.0,
+            1.0
+          ]
+        },
+        {
+          "pos": [
+            -128.0,
+            -192.0
+          ],
+          "color": [
+            0.0,
+            1.0,
+            0.0,
+            1.0
+          ]
+        },
+        {
+          "pos": [
+            128.0,
+            -192.0
+          ],
+          "color": [
+            0.0,
+            0.0,
+            1.0,
+            1.0
+          ]
+        }
+      ],
+      "platforms": [
+        {
+          "pos": [
+            0.0,
+            -256.0
+          ],
+          "size": [
+            800.0,
+            16.0
+          ]
+        }
+      ],
+      "absorbing_filters": [],
+      "rotating_filters": [],
+      "texts": [
+        {
+          "pos": [
+            0.0,
+            0.0
+          ],
+          "font_size": 32.0,
+          "text": "Combine the colors to synthetize a white light.\nUse arrows to move."
+        }
+      ]
+    },
+    {
+      "comment": "Switch tutorial",
+      "characters": [
+        {
+          "pos": [
+            0.0,
+            -192.0
+          ],
+          "color": [
+            0.0,
+            1.0,
+            0.0,
+            1.0
+          ]
+        },
+        {
+          "pos": [
+            -128.0,
+            -192.0
+          ],
+          "color": [
+            1.0,
+            0.0,
+            0.0,
+            1.0
+          ]
+        },
+        {
+          "pos": [
+            128.0,
+            320.0
+          ],
+          "color": [
+            0.0,
+            0.0,
+            1.0,
+            1.0
+          ]
+        }
+      ],
+      "platforms": [
+        {
+          "pos": [
+            0.0,
+            -256.0
+          ],
+          "size": [
+            800.0,
+            16.0
+          ]
+        },
+        {
+          "pos": [
+            128.0,
+            256.0
+          ],
+          "size": [
+            96.0,
+            16.0
+          ]
+        }
+      ],
+      "absorbing_filters": [],
+      "rotating_filters": [],
+      "texts": [
+        {
+          "pos": [
+            0.0,
+            0.0
+          ],
+          "font_size": 32.0,
+          "text": "Press Tab to switch."
+        }
+      ]
+    },
+    {
+      "comment": "Absorbing filter tutorial",
+      "characters": [
+        {
+          "pos": [
+            -128.0,
+            -192.0
+          ],
+          "color": [
+            1.0,
+            0.64,
+            0.0,
+            1.0
+          ]
+        },
+        {
+          "pos": [
+            128.0,
+            -192.0
+          ],
+          "color": [
+            0.0,
+            0.37,
+            1.0,
+            1.0
+          ]
+        }
+      ],
+      "platforms": [
+        {
+          "pos": [
+            0.0,
+            -256.0
+          ],
+          "size": [
+            800.0,
+            16.0
+          ]
+        },
+        {
+          "pos": [
+            0.0,
+            -128.0
+          ],
+          "size": [
+            800.0,
+            16.0
+          ]
+        }
+      ],
+      "absorbing_filters": [
+        {
+          "pos": [
+            0.0,
+            -192.0
+          ],
+          "size": [
+            16.0,
+            112.0
+          ],
+          "color": [
+            1.0,
+            0.0,
+            0.0,
+            1.0
+          ]
+        }
+      ],
+      "rotating_filters": [],
+      "texts": [
+        {
+          "pos": [
+            0.0,
+            0.0
+          ],
+          "font_size": 32.0,
+          "text": "Press R to reset."
+        }
+      ]
+    },
+    {
+      "comment": "Rotating filter tutorial",
+      "characters": [
+        {
+          "pos": [
+            0.0,
+            -192.0
+          ],
+          "color": [
+            1.0,
+            0.0,
+            0.0,
+            1.0
+          ]
+        },
+        {
+          "pos": [
+            -128.0,
+            -192.0
+          ],
+          "color": [
+            1.0,
+            0.0,
+            0.0,
+            1.0
+          ]
+        },
+        {
+          "pos": [
+            128.0,
+            -192.0
+          ],
+          "color": [
+            1.0,
+            0.0,
+            0.0,
+            1.0
+          ]
+        }
+      ],
+      "platforms": [
+        {
+          "pos": [
+            0.0,
+            -256.0
+          ],
+          "size": [
+            800.0,
+            16.0
+          ]
+        }
+      ],
+      "absorbing_filters": [],
+      "rotating_filters": [
+        {
+          "pos": [
+            0.0,
+            -64.0
+          ],
+          "angle": 45.0
+        }
+      ],
+      "texts": [
+        {
+          "pos": [
+            0.0,
+            0.0
+          ],
+          "font_size": 32.0,
+          "text": "Let's rotate the hue!"
+        }
+      ]
+    },
+    {
+      "comment": "Game over",
+      "characters": [
+        {
+          "pos": [
+            0.0,
+            -64.0
+          ],
+          "color": [
+            1.0,
+            0.0,
+            0.0,
+            1.0
+          ]
+        }
+      ],
+      "platforms": [
+        {
+          "pos": [
+            0.0,
+            -256.0
+          ],
+          "size": [
+            800.0,
+            16.0
+          ]
+        }
+      ],
+      "absorbing_filters": [],
+      "rotating_filters": [],
+      "texts": [
+        {
+          "pos": [
+            0.0,
+            128.0
+          ],
+          "font_size": 48.0,
+          "text": "Thank you for playing!"
+        },
+        {
+          "pos": [
+            0.0,
+            0.0
+          ],
+          "font_size": 32.0,
+          "text": "There is no more light to combine."
+        }
+      ]
+    }
+  ]
 }
\ No newline at end of file
diff --git a/build-wasm.sh b/build-wasm.sh
index b9643dc..0a2d039 100644
--- a/build-wasm.sh
+++ b/build-wasm.sh
@@ -1,2 +1,3 @@
-cargo build --release --target wasm32-unknown-unknown
-wasm-bindgen --out-name bevyjam --out-dir target --target web target/wasm32-unknown-unknown/release/bevyjam.wasm
+cargo build --release --target wasm32-unknown-unknown || exit 1
+echo "==> wasm-bindgen..."
+wasm-bindgen --out-name bevyjam --out-dir target --target web target/wasm32-unknown-unknown/release/bevyjam.wasm || exit 1
diff --git a/src/editor.rs b/src/editor.rs
new file mode 100644
index 0000000..55c1592
--- /dev/null
+++ b/src/editor.rs
@@ -0,0 +1,613 @@
+#![allow(clippy::type_complexity)]
+use crate::{levels::stored::*, AppState};
+
+use bevy::{
+	input::{keyboard::KeyCode, Input},
+	prelude::{
+		shape::{Circle, Quad},
+		*,
+	},
+	sprite::Mesh2dHandle,
+};
+use bevy_mod_picking::*;
+
+pub struct EditorPlugin;
+
+impl Plugin for EditorPlugin {
+	fn build(&self, app: &mut App) {
+		app.add_event::<DragEndEvent>()
+			.add_plugins(DefaultPickingPlugins)
+			.add_system_set(SystemSet::on_enter(AppState::Editor).with_system(setup))
+			.add_system_set(
+				SystemSet::on_update(AppState::Editor)
+					.with_system(move_system)
+					.with_system(input_control_system)
+					.with_system(follow_ends_system),
+			);
+	}
+}
+
+// Events
+
+struct DragEndEvent(Entity);
+
+// Resources
+
+struct CharacterList(Vec<Entity>);
+
+// Components
+
+#[derive(Component)]
+struct Platform;
+
+#[derive(Component)]
+struct Ends(Entity, Entity);
+
+#[derive(Component)]
+struct Draggable;
+
+#[derive(Component)]
+struct End(Entity);
+
+#[derive(Component)]
+struct CharacterColor(Color);
+
+#[derive(Component)]
+struct Size(Vec2);
+
+#[derive(Component)]
+struct RotatingFilterAngle(f32);
+
+#[derive(Component)]
+struct AbsorbingFilterColor(Color);
+
+// Bundles
+
+#[derive(Bundle)]
+struct PlatformBundle {
+	#[bundle]
+	mesh: ColorMesh2dBundle,
+	size: Size,
+	platform: Platform,
+}
+
+#[derive(Bundle)]
+struct EndBundle {
+	#[bundle]
+	mesh: ColorMesh2dBundle,
+	#[bundle]
+	pickable: PickableBundle,
+	draggable: Draggable,
+	end: End,
+}
+
+#[derive(Bundle)]
+struct CharacterBundle {
+	#[bundle]
+	mesh: ColorMesh2dBundle,
+	color: CharacterColor,
+	#[bundle]
+	pickable: PickableBundle,
+	draggable: Draggable,
+}
+
+#[derive(Bundle)]
+struct AbsorbingFilterBundle {
+	#[bundle]
+	mesh: ColorMesh2dBundle,
+	size: Size,
+	color: AbsorbingFilterColor,
+}
+
+#[derive(Bundle)]
+struct RotatingFilterBundle {
+	#[bundle]
+	mesh: ColorMesh2dBundle,
+	angle: RotatingFilterAngle,
+	#[bundle]
+	pickable: PickableBundle,
+	draggable: Draggable,
+}
+
+// Functions
+
+fn spawn_platform(
+	commands: &mut Commands,
+	meshes: &mut ResMut<Assets<Mesh>>,
+	materials: &mut ResMut<Assets<ColorMaterial>>,
+
+	transform: Transform,
+	size: Vec2,
+) {
+	let platform = commands
+		.spawn_bundle(PlatformBundle {
+			mesh: ColorMesh2dBundle {
+				mesh: meshes.add(Mesh::from(Quad { size, flip: false })).into(),
+				material: materials.add(ColorMaterial::from(Color::GRAY)),
+				transform,
+				..default()
+			},
+			size: Size(size),
+			platform: Platform,
+		})
+		.id();
+	let ends = Ends(
+		commands
+			.spawn_bundle(EndBundle {
+				mesh: ColorMesh2dBundle {
+					mesh: meshes
+						.add(Mesh::from(Circle {
+							radius: 8.,
+							vertices: 12,
+						}))
+						.into(),
+					material: materials.add(ColorMaterial::from(Color::rgba(1., 1., 0., 0.7))),
+					transform: Transform::from_xyz(
+						transform.translation.x - size.x / 2.,
+						transform.translation.y - size.y / 2.,
+						0.5,
+					),
+					..default()
+				},
+				pickable: PickableBundle::default(),
+				draggable: Draggable,
+				end: End(platform),
+			})
+			.id(),
+		commands
+			.spawn_bundle(EndBundle {
+				mesh: ColorMesh2dBundle {
+					mesh: meshes
+						.add(Mesh::from(Circle {
+							radius: 8.,
+							vertices: 12,
+						}))
+						.into(),
+					material: materials.add(ColorMaterial::from(Color::rgba(1., 1., 0., 0.7))),
+					transform: Transform::from_xyz(
+						transform.translation.x + size.x / 2.,
+						transform.translation.y + size.y / 2.,
+						0.5,
+					),
+					..default()
+				},
+				pickable: PickableBundle::default(),
+				draggable: Draggable,
+				end: End(platform),
+			})
+			.id(),
+	);
+	commands.entity(platform).insert(ends);
+}
+
+fn spawn_character(
+	commands: &mut Commands,
+	meshes: &mut ResMut<Assets<Mesh>>,
+	materials: &mut ResMut<Assets<ColorMaterial>>,
+	asset_server: &Res<AssetServer>,
+
+	transform: Transform,
+	color: Color,
+	index: usize,
+) -> Entity {
+	let font = asset_server.get_handle("UacariLegacy-Thin.ttf");
+	commands
+		.spawn_bundle(CharacterBundle {
+			mesh: ColorMesh2dBundle {
+				mesh: meshes
+					.add(Mesh::from(Quad {
+						size: Vec2 { x: 64., y: 64. },
+						flip: false,
+					}))
+					.into(),
+				material: materials.add(ColorMaterial::from(color)),
+				transform,
+				..default()
+			},
+			color: CharacterColor(color),
+			pickable: PickableBundle::default(),
+			draggable: Draggable,
+		})
+		.with_children(|c| {
+			c.spawn_bundle(Text2dBundle {
+				text: Text::from_section(
+					&index.to_string(),
+					TextStyle {
+						font: font.clone(),
+						font_size: 32.,
+						color: Color::WHITE,
+					},
+				)
+				.with_alignment(TextAlignment::CENTER),
+				transform: Transform::from_xyz(0., 0., 1.),
+				..Default::default()
+			});
+		})
+		.id()
+}
+
+fn spawn_absorbing_filter(
+	commands: &mut Commands,
+	meshes: &mut ResMut<Assets<Mesh>>,
+	materials: &mut ResMut<Assets<ColorMaterial>>,
+
+	transform: Transform,
+	size: Vec2,
+	color: Color,
+) {
+	let absorbing_filter = commands
+		.spawn_bundle(AbsorbingFilterBundle {
+			mesh: ColorMesh2dBundle {
+				mesh: meshes.add(Mesh::from(Quad { size, flip: false })).into(),
+				material: materials.add(ColorMaterial::from(color)),
+				transform,
+				..default()
+			},
+			size: Size(size),
+			color: AbsorbingFilterColor(color),
+		})
+		.id();
+	let ends = Ends(
+		commands
+			.spawn_bundle(EndBundle {
+				mesh: ColorMesh2dBundle {
+					mesh: meshes
+						.add(Mesh::from(Circle {
+							radius: 8.,
+							vertices: 12,
+						}))
+						.into(),
+					material: materials.add(ColorMaterial::from(Color::rgba(1., 1., 0., 0.7))),
+					transform: Transform::from_xyz(
+						transform.translation.x - size.x / 2.,
+						transform.translation.y - size.y / 2.,
+						0.5,
+					),
+					..default()
+				},
+				pickable: PickableBundle::default(),
+				draggable: Draggable,
+				end: End(absorbing_filter),
+			})
+			.id(),
+		commands
+			.spawn_bundle(EndBundle {
+				mesh: ColorMesh2dBundle {
+					mesh: meshes
+						.add(Mesh::from(Circle {
+							radius: 8.,
+							vertices: 12,
+						}))
+						.into(),
+					material: materials.add(ColorMaterial::from(Color::rgba(1., 1., 0., 0.7))),
+					transform: Transform::from_xyz(
+						transform.translation.x + size.x / 2.,
+						transform.translation.y + size.y / 2.,
+						0.5,
+					),
+					..default()
+				},
+				pickable: PickableBundle::default(),
+				draggable: Draggable,
+				end: End(absorbing_filter),
+			})
+			.id(),
+	);
+	commands.entity(absorbing_filter).insert(ends);
+}
+
+fn spawn_rotating_filter(
+	commands: &mut Commands,
+	meshes: &mut ResMut<Assets<Mesh>>,
+	materials: &mut ResMut<Assets<ColorMaterial>>,
+	asset_server: &Res<AssetServer>,
+
+	transform: Transform,
+	angle: f32,
+) -> Entity {
+	let font = asset_server.get_handle("UacariLegacy-Thin.ttf");
+	commands
+		.spawn_bundle(RotatingFilterBundle {
+			mesh: ColorMesh2dBundle {
+				mesh: meshes
+					.add(Mesh::from(Circle {
+						radius: 32.,
+						vertices: 36,
+					}))
+					.into(),
+				material: materials.add(ColorMaterial::from(Color::WHITE)),
+				transform,
+				..default()
+			},
+			angle: RotatingFilterAngle(angle),
+			pickable: PickableBundle::default(),
+			draggable: Draggable,
+		})
+		.with_children(|c| {
+			c.spawn_bundle(Text2dBundle {
+				text: Text::from_section(
+					&angle.to_string(),
+					TextStyle {
+						font: font.clone(),
+						font_size: 32.,
+						color: Color::RED,
+					},
+				)
+				.with_alignment(TextAlignment::CENTER),
+				transform: Transform::from_xyz(0., 0., 1.),
+				..Default::default()
+			});
+		})
+		.id()
+}
+
+fn spawn_stored_level(
+	commands: &mut Commands,
+	meshes: &mut ResMut<Assets<Mesh>>,
+	materials: &mut ResMut<Assets<ColorMaterial>>,
+	asset_server: &Res<AssetServer>,
+
+	stored_level: &StoredLevel,
+) {
+	for platform in stored_level.platforms.iter() {
+		spawn_platform(
+			commands,
+			meshes,
+			materials,
+			Transform::from_xyz(platform.pos.x, platform.pos.y, 0.),
+			platform.size,
+		);
+	}
+
+	let mut character_list = Vec::new();
+	for (i, character) in stored_level.characters.iter().enumerate() {
+		character_list.push(spawn_character(
+			commands,
+			meshes,
+			materials,
+			asset_server,
+			Transform::from_xyz(character.pos.x, character.pos.y, 0.),
+			character.color.into(),
+			i,
+		));
+	}
+	commands.insert_resource(CharacterList(character_list));
+
+	for absorbing_filter in stored_level.absorbing_filters.iter() {
+		spawn_absorbing_filter(
+			commands,
+			meshes,
+			materials,
+			Transform::from_xyz(absorbing_filter.pos.x, absorbing_filter.pos.y, 0.),
+			absorbing_filter.size,
+			absorbing_filter.color.into(),
+		);
+	}
+
+	for rotating_filter in stored_level.rotating_filters.iter() {
+		spawn_rotating_filter(
+			commands,
+			meshes,
+			materials,
+			asset_server,
+			Transform::from_xyz(rotating_filter.pos.x, rotating_filter.pos.y, 0.),
+			rotating_filter.angle,
+		);
+	}
+}
+
+fn save_level(
+	level_id: &Res<crate::game::FirstLevel>,
+	stored_levels_assets: &mut ResMut<Assets<StoredLevels>>,
+	stored_levels_handle: &Res<Handle<StoredLevels>>,
+	character_list: &Res<CharacterList>,
+	character_query: &Query<(&Transform, &CharacterColor), Without<Platform>>,
+	platform_query: &Query<(&Transform, &Size), With<Platform>>,
+	absorbing_filter_query: &Query<(&Transform, &Size, &AbsorbingFilterColor), Without<Platform>>,
+	rotating_filter_query: &Query<
+		(&Transform, &RotatingFilterAngle),
+		(Without<Platform>, Without<CharacterColor>),
+	>,
+) {
+	let stored_levels = stored_levels_assets.get_mut(stored_levels_handle).unwrap();
+	if let Some(stored_level) = stored_levels.levels.get_mut(level_id.0 .0 as usize) {
+		stored_level.platforms.clear();
+		for (transform, size) in platform_query.iter() {
+			stored_level.platforms.push(StoredPlatform {
+				pos: Vec2 {
+					x: transform.translation.x,
+					y: transform.translation.y,
+				},
+				size: size.0,
+			})
+		}
+
+		stored_level.characters.clear();
+		for entity in character_list.0.iter() {
+			if let Ok((transform, color)) = character_query.get(*entity) {
+				stored_level.characters.push(StoredCharacter {
+					pos: Vec2 {
+						x: transform.translation.x,
+						y: transform.translation.y,
+					},
+					color: color.0.into(),
+				})
+			}
+		}
+
+		stored_level.absorbing_filters.clear();
+		for (transform, size, color) in absorbing_filter_query.iter() {
+			stored_level.absorbing_filters.push(StoredAbsorbingFilter {
+				pos: Vec2 {
+					x: transform.translation.x,
+					y: transform.translation.y,
+				},
+				size: size.0,
+				color: color.0.into(),
+			})
+		}
+
+		stored_level.rotating_filters.clear();
+		for (transform, angle) in rotating_filter_query.iter() {
+			stored_level.rotating_filters.push(StoredRotatingFilter {
+				pos: Vec2 {
+					x: transform.translation.x,
+					y: transform.translation.y,
+				},
+				angle: angle.0,
+			})
+		}
+	} else {
+		return;
+	}
+	match std::fs::OpenOptions::new()
+		.write(true)
+		.truncate(true)
+		.open("assets/game.levels.json")
+	{
+		Ok(mut file) => {
+			serde_json::to_writer_pretty(&mut file, stored_levels).unwrap();
+			println!("Saved!");
+		}
+		Err(e) => eprintln!("Error writing levels file: {:?}", e),
+	}
+}
+
+// Systems
+
+fn setup(
+	mut commands: Commands,
+	mut meshes: ResMut<Assets<Mesh>>,
+	mut materials: ResMut<Assets<ColorMaterial>>,
+	camera_query: Query<Entity, With<Camera>>,
+	level_id: Res<crate::game::FirstLevel>,
+	stored_levels_assets: Res<Assets<StoredLevels>>,
+	stored_levels_handle: Res<Handle<StoredLevels>>,
+	asset_server: Res<AssetServer>,
+) {
+	commands
+		.entity(camera_query.single())
+		.insert_bundle(PickingCameraBundle::default());
+
+	if let Some(stored_level) = stored_levels_assets
+		.get(&stored_levels_handle)
+		.unwrap()
+		.levels
+		.get(level_id.0 .0 as usize)
+	{
+		spawn_stored_level(
+			&mut commands,
+			&mut meshes,
+			&mut materials,
+			&asset_server,
+			stored_level,
+		);
+	}
+}
+
+fn input_control_system(
+	keyboard_input: Res<Input<KeyCode>>,
+	level_id: Res<crate::game::FirstLevel>,
+	mut stored_levels_assets: ResMut<Assets<StoredLevels>>,
+	stored_levels_handle: Res<Handle<StoredLevels>>,
+	character_list: Res<CharacterList>,
+	character_query: Query<(&Transform, &CharacterColor), Without<Platform>>,
+	platform_query: Query<(&Transform, &Size), With<Platform>>,
+	absorbing_filter_query: Query<(&Transform, &Size, &AbsorbingFilterColor), Without<Platform>>,
+	rotating_filter_query: Query<
+		(&Transform, &RotatingFilterAngle),
+		(Without<Platform>, Without<CharacterColor>),
+	>,
+) {
+	if keyboard_input.just_released(KeyCode::S)
+		&& (keyboard_input.pressed(KeyCode::LControl) || keyboard_input.pressed(KeyCode::RControl))
+	{
+		save_level(
+			&level_id,
+			&mut stored_levels_assets,
+			&stored_levels_handle,
+			&character_list,
+			&character_query,
+			&platform_query,
+			&absorbing_filter_query,
+			&rotating_filter_query,
+		);
+	}
+}
+
+fn move_system(
+	keyboard_input: Res<Input<KeyCode>>,
+	mut camera_query: Query<&mut Transform, (With<Camera>, Without<Draggable>)>,
+	mut drag_query: Query<(&mut Transform, &Selection, Option<&End>), With<Draggable>>,
+	mut drag_end_event: EventWriter<DragEndEvent>,
+) {
+	if keyboard_input.pressed(KeyCode::LControl) || keyboard_input.pressed(KeyCode::RControl) {
+		let mut transform = camera_query.single_mut();
+		let drag = Vec3 {
+			x: (keyboard_input.pressed(KeyCode::Right) as i8
+				- keyboard_input.pressed(KeyCode::Left) as i8) as _,
+			y: (keyboard_input.pressed(KeyCode::Up) as i8
+				- keyboard_input.pressed(KeyCode::Down) as i8) as _,
+			z: 0.,
+		} * 8.;
+		transform.translation += drag;
+		return;
+	}
+
+	let drag = if keyboard_input.pressed(KeyCode::LShift) || keyboard_input.pressed(KeyCode::RShift)
+	{
+		Vec3 {
+			x: (keyboard_input.pressed(KeyCode::Right) as i8
+				- keyboard_input.pressed(KeyCode::Left) as i8) as _,
+			y: (keyboard_input.pressed(KeyCode::Up) as i8
+				- keyboard_input.pressed(KeyCode::Down) as i8) as _,
+			z: 0.,
+		}
+	} else {
+		Vec3 {
+			x: (keyboard_input.just_pressed(KeyCode::Right) as i8
+				- keyboard_input.just_pressed(KeyCode::Left) as i8) as _,
+			y: (keyboard_input.just_pressed(KeyCode::Up) as i8
+				- keyboard_input.just_pressed(KeyCode::Down) as i8) as _,
+			z: 0.,
+		}
+	} * 8.;
+	if drag != Vec3::ZERO {
+		for (mut transform, selection, end) in drag_query.iter_mut() {
+			if selection.selected() {
+				transform.translation += drag;
+				if let Some(End(entity)) = end {
+					drag_end_event.send(DragEndEvent(*entity));
+				}
+			}
+		}
+	}
+}
+
+fn follow_ends_system(
+	mut meshes: ResMut<Assets<Mesh>>,
+	mut follower_query: Query<(&mut Transform, &mut Mesh2dHandle, &mut Size, &Ends)>,
+	end_query: Query<&Transform, Without<Ends>>,
+	mut drag_end_event: EventReader<DragEndEvent>,
+) {
+	for DragEndEvent(entity) in drag_end_event.iter() {
+		if let Ok((mut transform, mut mesh, mut size, Ends(end1, end2))) =
+			follower_query.get_mut(*entity)
+		{
+			if let (Ok(end1), Ok(end2)) = (end_query.get(*end1), end_query.get(*end2)) {
+				transform.translation.x = (end1.translation.x + end2.translation.x) / 2.;
+				transform.translation.y = (end1.translation.y + end2.translation.y) / 2.;
+				size.0 = Vec2 {
+					x: (end2.translation.x - end1.translation.x).abs(),
+					y: (end2.translation.y - end1.translation.y).abs(),
+				};
+				*mesh = meshes
+					.add(Mesh::from(Quad {
+						size: size.0,
+						flip: false,
+					}))
+					.into();
+			}
+		}
+	}
+}
diff --git a/src/game.rs b/src/game.rs
index 102a44e..32f3bb0 100644
--- a/src/game.rs
+++ b/src/game.rs
@@ -471,14 +471,13 @@ fn move_camera(
 
 		let size: Vec2 = camera.logical_viewport_size().unwrap();
 		let half_height: f32 = size.y * 0.5;
-        let mut target_translation = character_transform.translation;
+		let mut target_translation = character_transform.translation;
 		// prevent camera from going too low
 		target_translation.y = target_translation.y.max(half_height - MARGIN);
 
-		camera_transform.translation = camera_transform.translation.lerp(
-			target_translation,
-			time.delta_seconds() * FOLLOW_SPEED,
-		);
+		camera_transform.translation = camera_transform
+			.translation
+			.lerp(target_translation, time.delta_seconds() * FOLLOW_SPEED);
 
 		// always make sure that camera is away from the object in order to render them
 		camera_transform.translation.z = 999.0;
diff --git a/src/levels.rs b/src/levels.rs
index c131e63..f99b429 100644
--- a/src/levels.rs
+++ b/src/levels.rs
@@ -1,5 +1,7 @@
 #![allow(clippy::too_many_arguments)]
 
+pub use stored::*;
+
 use crate::game::*;
 
 use bevy::{prelude::*, reflect::TypeUuid};
@@ -55,60 +57,6 @@ pub fn post_setup_level(
 	}
 }
 
-#[derive(Deserialize, Serialize, TypeUuid)]
-#[uuid = "1fbba930-644b-0d62-2514-4b302b945327"]
-pub struct StoredLevels {
-	levels: Vec<StoredLevel>,
-}
-
-#[derive(Deserialize, Serialize, TypeUuid)]
-#[uuid = "a1464a30-1f57-a654-d56c-ded41032af0b"]
-pub struct StoredLevel {
-	pub comment: String,
-	pub characters: Vec<StoredCharacter>,
-	pub platforms: Vec<StoredPlatform>,
-	pub absorbing_filters: Vec<StoredAbsorbingFilter>,
-	pub rotating_filters: Vec<StoredRotatingFilter>,
-	pub texts: Vec<StoredText>,
-}
-
-#[derive(Deserialize, Serialize, TypeUuid)]
-#[uuid = "1c798f8c-ef15-c528-693e-76becdef6b10"]
-pub struct StoredCharacter {
-	pub pos: Vec2,
-	pub color: Vec4,
-}
-
-#[derive(Deserialize, Serialize, TypeUuid)]
-#[uuid = "31696095-59de-93be-b5e9-333c2afbc900"]
-pub struct StoredPlatform {
-	pub pos: Vec2,
-	pub size: Vec2,
-}
-
-#[derive(Deserialize, Serialize, TypeUuid)]
-#[uuid = "bcad7fff-0605-c4e3-3cd4-42d5bbaad926"]
-pub struct StoredAbsorbingFilter {
-	pub pos: Vec2,
-	pub size: Vec2,
-	pub color: Vec4,
-}
-
-#[derive(Deserialize, Serialize, TypeUuid)]
-#[uuid = "fa2843f2-6e34-601b-6c46-4827b0370b3f"]
-pub struct StoredRotatingFilter {
-	pub pos: Vec2,
-	pub angle: f32,
-}
-
-#[derive(Deserialize, Serialize, TypeUuid)]
-#[uuid = "72f6321a-f01f-6eea-9b17-3159837a2fd3"]
-pub struct StoredText {
-	pub pos: Vec2,
-	pub font_size: f32,
-	pub text: String,
-}
-
 pub fn spawn_stored_level(
 	commands: &mut Commands,
 	character_meshes: &Res<CharacterMeshes>,
@@ -179,3 +127,61 @@ pub fn spawn_stored_level(
 			.insert(Level);
 	}
 }
+
+pub mod stored {
+	use super::*;
+
+	#[derive(Deserialize, Serialize, TypeUuid)]
+	#[uuid = "1fbba930-644b-0d62-2514-4b302b945327"]
+	pub struct StoredLevels {
+		pub levels: Vec<StoredLevel>,
+	}
+
+	#[derive(Deserialize, Serialize, TypeUuid)]
+	#[uuid = "a1464a30-1f57-a654-d56c-ded41032af0b"]
+	pub struct StoredLevel {
+		pub comment: String,
+		pub characters: Vec<StoredCharacter>,
+		pub platforms: Vec<StoredPlatform>,
+		pub absorbing_filters: Vec<StoredAbsorbingFilter>,
+		pub rotating_filters: Vec<StoredRotatingFilter>,
+		pub texts: Vec<StoredText>,
+	}
+
+	#[derive(Deserialize, Serialize, TypeUuid)]
+	#[uuid = "1c798f8c-ef15-c528-693e-76becdef6b10"]
+	pub struct StoredCharacter {
+		pub pos: Vec2,
+		pub color: Vec4,
+	}
+
+	#[derive(Deserialize, Serialize, TypeUuid)]
+	#[uuid = "31696095-59de-93be-b5e9-333c2afbc900"]
+	pub struct StoredPlatform {
+		pub pos: Vec2,
+		pub size: Vec2,
+	}
+
+	#[derive(Deserialize, Serialize, TypeUuid)]
+	#[uuid = "bcad7fff-0605-c4e3-3cd4-42d5bbaad926"]
+	pub struct StoredAbsorbingFilter {
+		pub pos: Vec2,
+		pub size: Vec2,
+		pub color: Vec4,
+	}
+
+	#[derive(Deserialize, Serialize, TypeUuid)]
+	#[uuid = "fa2843f2-6e34-601b-6c46-4827b0370b3f"]
+	pub struct StoredRotatingFilter {
+		pub pos: Vec2,
+		pub angle: f32,
+	}
+
+	#[derive(Deserialize, Serialize, TypeUuid)]
+	#[uuid = "72f6321a-f01f-6eea-9b17-3159837a2fd3"]
+	pub struct StoredText {
+		pub pos: Vec2,
+		pub font_size: f32,
+		pub text: String,
+	}
+}
diff --git a/src/main.rs b/src/main.rs
index 410492a..7976caa 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,9 @@
+#![allow(clippy::too_many_arguments)]
+
 #[cfg(not(target_arch = "wasm32"))]
 mod audio;
+#[cfg(not(target_arch = "wasm32"))]
+mod editor;
 mod filters;
 mod game;
 mod levels;
@@ -7,6 +11,7 @@ mod menu;
 mod particle_effect;
 
 use bevy::{
+	asset::{Asset, HandleId, LoadState},
 	prelude::*,
 	window::{WindowId, WindowMode},
 };
@@ -15,9 +20,22 @@ use bevy_rapier2d::prelude::*;
 
 #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
 enum AppState {
+	Loading,
 	Menu,
 	Game,
 	Win,
+	Editor,
+}
+
+struct UseEditor(bool);
+
+struct LoadingAssets(Vec<HandleId>);
+
+impl LoadingAssets {
+	fn add<T: Asset>(&mut self, handle: Handle<T>) -> Handle<T> {
+		self.0.push(handle.id);
+		handle
+	}
 }
 
 fn main() {
@@ -28,32 +46,45 @@ fn main() {
 	std::thread::spawn(move || audio::setup(audio_event_receiver));
 
 	#[cfg(not(target_arch = "wasm32"))]
-	let first_level = game::LevelId(
-		std::env::args()
-			.nth(1)
-			.map_or(0, |s| s.parse().unwrap_or(0)),
-	);
+	let (first_level, use_editor) = {
+		let mut args = std::env::args().skip(1);
+		(
+			game::LevelId(args.next().map_or(0, |s| s.parse().unwrap_or(0))),
+			args.next().map_or(false, |s| s == "e"),
+		)
+	};
 	#[cfg(target_arch = "wasm32")]
-	let first_level = game::LevelId(0);
+	let (first_level, use_editor) = (game::LevelId(0), false);
 
-	App::new()
-		.insert_resource(Msaa { samples: 4 })
+	let mut app = App::new();
+	app.insert_resource(Msaa { samples: 4 })
 		.insert_resource(audio_event_sender)
-		.add_state(AppState::Menu)
+		.insert_resource(UseEditor(use_editor))
+		.add_state(AppState::Loading)
 		.insert_resource(game::FirstLevel(first_level))
 		.insert_resource(ClearColor(Color::BLACK))
 		.add_plugins(DefaultPlugins)
+		//.add_plugin(RapierDebugRenderPlugin::default())
+		//.add_plugin(bevy_inspector_egui::WorldInspectorPlugin::new())
 		.add_plugin(JsonAssetPlugin::<levels::StoredLevels>::new(&[
 			"levels.json",
-		]))
-		.add_plugin(RapierPhysicsPlugin::<NoUserData>::pixels_per_meter(64.0))
-		//.add_plugin(RapierDebugRenderPlugin::default())
-		.add_plugin(menu::MenuPlugin)
-		.add_plugin(game::GamePlugin)
-		.add_plugin(particle_effect::ParticleEffectPlugin)
-		//.add_plugin(bevy_inspector_egui::WorldInspectorPlugin::new())
-		.add_system(keyboard_util_system)
+		]));
+
+	if !use_editor {
+		app.add_plugin(RapierPhysicsPlugin::<NoUserData>::pixels_per_meter(64.0))
+			.add_plugin(menu::MenuPlugin)
+			.add_plugin(game::GamePlugin)
+			.add_plugin(particle_effect::ParticleEffectPlugin);
+	}
+
+	#[cfg(not(target_arch = "wasm32"))]
+	if use_editor {
+		app.add_plugin(editor::EditorPlugin);
+	}
+
+	app.add_system(keyboard_util_system)
 		.add_startup_system(setup)
+		.add_system_set(SystemSet::on_update(AppState::Loading).with_system(loading_system))
 		.run();
 }
 
@@ -63,9 +94,13 @@ fn setup(mut commands: Commands, mut windows: ResMut<Windows>, asset_server: Res
 		.unwrap()
 		.set_title(String::from("Bevyjam"));
 
-	commands.insert_resource(asset_server.load::<levels::StoredLevels, _>("game.levels.json"));
-	commands.insert_resource(asset_server.load::<Font, _>("UacariLegacy-Thin.ttf"));
-	commands.insert_resource(asset_server.load::<Image, _>("bevy.png"));
+	let mut assets = LoadingAssets(Vec::new());
+	commands.insert_resource(
+		assets.add(asset_server.load::<levels::StoredLevels, _>("game.levels.json")),
+	);
+	commands.insert_resource(assets.add(asset_server.load::<Font, _>("UacariLegacy-Thin.ttf")));
+	commands.insert_resource(assets.add(asset_server.load::<Image, _>("bevy.png")));
+	commands.insert_resource(assets);
 
 	commands.spawn_bundle(Camera2dBundle::default());
 	commands.insert_resource(AmbientLight {
@@ -74,6 +109,23 @@ fn setup(mut commands: Commands, mut windows: ResMut<Windows>, asset_server: Res
 	});
 }
 
+fn loading_system(
+	asset_server: Res<AssetServer>,
+	use_editor: Res<UseEditor>,
+	assets: Res<LoadingAssets>,
+	mut app_state: ResMut<State<AppState>>,
+) {
+	if asset_server.get_group_load_state(assets.0.iter().copied()) == LoadState::Loaded {
+		app_state
+			.replace(if use_editor.0 {
+				AppState::Editor
+			} else {
+				AppState::Menu
+			})
+			.ok();
+	}
+}
+
 fn keyboard_util_system(keyboard_input: Res<Input<KeyCode>>, mut windows: ResMut<Windows>) {
 	#[cfg(not(target_arch = "wasm32"))]
 	{
diff --git a/src/particle_effect.rs b/src/particle_effect.rs
index c1ee094..b711548 100644
--- a/src/particle_effect.rs
+++ b/src/particle_effect.rs
@@ -44,7 +44,6 @@ impl FromWorld for ParticleMesh {
 	}
 }
 
-#[derive(bevy_inspector_egui::Inspectable)]
 pub struct ParticleEffectResource {
 	pub translation: Vec3,
 	pub prev_translation: Vec3,