Level Design Tools
Creating Powerful Level Design Tools
Level design tools empower creators to build engaging game worlds efficiently! Learn object placement systems, entity spawning, triggers, scripting, path editing, and how to create intuitive visual level editors! đ¨đī¸đŽ
Understanding Level Design Systems
đī¸ The Architect's Toolkit Analogy
Think of level design tools like an architect's toolkit:
- Object Palette: Your building materials catalog
- Placement Grid: The blueprint grid
- Entity System: Furniture and fixtures
- Triggers: Automatic doors and sensors
- Paths: Planned routes through the space
- Testing: Walking through your design
Interactive Level Editor Demo
Design your level! Click to place objects, drag to create paths, test your creation!
Object Palette:
Mode: Place | Tool: Platform | Objects: 0 | Selected: 0
Mouse: (0, 0) | Grid: (0, 0) | Test Mode: Off
Level Editor Implementation
import pygame
import json
from enum import Enum
from typing import List, Dict, Optional, Tuple
class ObjectType(Enum):
"""Level object types"""
PLATFORM = "platform"
SPIKE = "spike"
COIN = "coin"
SPRING = "spring"
ENEMY = "enemy"
CHECKPOINT = "checkpoint"
MOVING_PLATFORM = "moving_platform"
PORTAL = "portal"
class LevelObject:
"""Base class for level objects"""
def __init__(self, obj_type: ObjectType, x: float, y: float,
width: float = 32, height: float = 32):
self.type = obj_type
self.x = x
self.y = y
self.width = width
self.height = height
self.properties = {}
self.selected = False
# Path for moving objects
self.path = []
self.path_index = 0
self.path_progress = 0.0
def get_rect(self) -> pygame.Rect:
"""Get collision rectangle"""
return pygame.Rect(self.x, self.y, self.width, self.height)
def update(self, dt: float):
"""Update object logic"""
if self.type == ObjectType.MOVING_PLATFORM and len(self.path) > 1:
self.update_path_movement(dt)
def update_path_movement(self, dt: float):
"""Update movement along path"""
if len(self.path) < 2:
return
speed = self.properties.get('speed', 50)
current = self.path[self.path_index]
next_point = self.path[(self.path_index + 1) % len(self.path)]
# Move along path
self.path_progress += speed * dt / 100
if self.path_progress >= 1.0:
self.path_progress = 0.0
self.path_index = (self.path_index + 1) % len(self.path)
# Interpolate position
t = self.path_progress
self.x = current[0] + (next_point[0] - current[0]) * t
self.y = current[1] + (next_point[1] - current[1]) * t
def to_dict(self) -> Dict:
"""Serialize to dictionary"""
return {
'type': self.type.value,
'x': self.x,
'y': self.y,
'width': self.width,
'height': self.height,
'properties': self.properties,
'path': self.path
}
@classmethod
def from_dict(cls, data: Dict) -> 'LevelObject':
"""Deserialize from dictionary"""
obj = cls(
ObjectType(data['type']),
data['x'],
data['y'],
data.get('width', 32),
data.get('height', 32)
)
obj.properties = data.get('properties', {})
obj.path = data.get('path', [])
return obj
class LevelEditor:
"""Visual level editor"""
def __init__(self, screen_width: int, screen_height: int):
self.screen_width = screen_width
self.screen_height = screen_height
# Level objects
self.objects: List[LevelObject] = []
self.selected_objects: List[LevelObject] = []
# Editor state
self.mode = 'place' # place, select, delete, path
self.current_tool = ObjectType.PLATFORM
self.grid_size = 32
self.snap_to_grid = True
self.show_grid = True
# Camera
self.camera_x = 0
self.camera_y = 0
# History for undo/redo
self.history = []
self.history_index = -1
self.max_history = 50
# Clipboard
self.clipboard = []
def place_object(self, x: float, y: float):
"""Place new object at position"""
if self.snap_to_grid:
x = round(x / self.grid_size) * self.grid_size
y = round(y / self.grid_size) * self.grid_size
# Default sizes for different object types
sizes = {
ObjectType.PLATFORM: (64, 32),
ObjectType.SPIKE: (32, 32),
ObjectType.COIN: (24, 24),
ObjectType.SPRING: (32, 32),
ObjectType.ENEMY: (32, 32),
ObjectType.CHECKPOINT: (32, 64),
ObjectType.MOVING_PLATFORM: (64, 32),
ObjectType.PORTAL: (48, 64)
}
width, height = sizes.get(self.current_tool, (32, 32))
obj = LevelObject(self.current_tool, x, y, width, height)
self.objects.append(obj)
self.save_history()
def select_at_point(self, x: float, y: float, add_to_selection: bool = False):
"""Select object at point"""
if not add_to_selection:
self.clear_selection()
for obj in reversed(self.objects): # Check top objects first
if obj.get_rect().collidepoint(x, y):
obj.selected = True
self.selected_objects.append(obj)
break
def clear_selection(self):
"""Clear all selections"""
for obj in self.selected_objects:
obj.selected = False
self.selected_objects.clear()
def delete_selected(self):
"""Delete selected objects"""
for obj in self.selected_objects:
if obj in self.objects:
self.objects.remove(obj)
self.clear_selection()
self.save_history()
def move_selected(self, dx: float, dy: float):
"""Move selected objects"""
for obj in self.selected_objects:
obj.x += dx
obj.y += dy
if self.snap_to_grid:
obj.x = round(obj.x / self.grid_size) * self.grid_size
obj.y = round(obj.y / self.grid_size) * self.grid_size
def copy_selected(self):
"""Copy selected objects to clipboard"""
self.clipboard = [obj.to_dict() for obj in self.selected_objects]
def paste(self, x: float, y: float):
"""Paste objects from clipboard"""
if not self.clipboard:
return
self.clear_selection()
# Calculate offset from first object
if self.clipboard:
offset_x = x - self.clipboard[0]['x']
offset_y = y - self.clipboard[0]['y']
for data in self.clipboard:
new_data = data.copy()
new_data['x'] += offset_x
new_data['y'] += offset_y
obj = LevelObject.from_dict(new_data)
obj.selected = True
self.objects.append(obj)
self.selected_objects.append(obj)
self.save_history()
def save_history(self):
"""Save current state to history"""
# Truncate future history
if self.history_index < len(self.history) - 1:
self.history = self.history[:self.history_index + 1]
# Save state
state = [obj.to_dict() for obj in self.objects]
self.history.append(state)
self.history_index += 1
# Limit history size
if len(self.history) > self.max_history:
self.history.pop(0)
self.history_index -= 1
def undo(self):
"""Undo last action"""
if self.history_index > 0:
self.history_index -= 1
self.restore_state(self.history[self.history_index])
def redo(self):
"""Redo action"""
if self.history_index < len(self.history) - 1:
self.history_index += 1
self.restore_state(self.history[self.history_index])
def restore_state(self, state: List[Dict]):
"""Restore objects from state"""
self.clear_selection()
self.objects = [LevelObject.from_dict(data) for data in state]
def save_level(self, filename: str):
"""Save level to file"""
level_data = {
'objects': [obj.to_dict() for obj in self.objects],
'properties': {
'width': self.screen_width,
'height': self.screen_height
}
}
with open(filename, 'w') as f:
json.dump(level_data, f, indent=2)
def load_level(self, filename: str):
"""Load level from file"""
with open(filename, 'r') as f:
level_data = json.load(f)
self.clear_selection()
self.objects = [LevelObject.from_dict(data)
for data in level_data['objects']]
self.save_history()
def validate_level(self) -> List[str]:
"""Validate level design"""
issues = []
# Check for spawn point
has_spawn = any(obj.type == ObjectType.CHECKPOINT
for obj in self.objects)
if not has_spawn:
issues.append("No spawn point/checkpoint")
# Check for unreachable areas
# (Simplified - would need pathfinding for full validation)
# Check for impossible jumps
# (Would need physics simulation)
return issues
Best Practices
⥠Level Design Tool Tips
- Grid Snapping: Align objects for clean layouts
- Object Palette: Quick access to common objects
- Undo/Redo: Essential for experimentation
- Copy/Paste: Speed up repetitive designs
- Test Mode: Immediate playtesting
- Validation: Check for design issues
- Prefabs: Reusable object groups
- Visual Feedback: Clear selection and placement
Key Takeaways
- đ¨ Visual editors speed up level creation
- 𧲠Grid snapping ensures alignment
- đ Copy/paste accelerates workflow
- âļ Undo/redo enables experimentation
- đ¤ī¸ Path editing creates dynamic levels
- âļī¸ Test mode provides instant feedback
- đž Save/load preserves work
- â Validation catches design issues
đī¸ââī¸ Practice Exercise
đī¸ââī¸ Exercise 1: Mini Level Editor â Place, Snap, Undo, Save in 80 Lines
Objective: Build a runnable pygame level editor that exercises the four pillar level-design patterns from the lesson â grid-snapping placement, snapshot-based undo via history list + index, JSON serialization round-trip, and design-time validation â in one program. This is the chat-46 M2 tilemap lesson seen from the OTHER side: tilemap is the runtime data structure that level_design AUTHORS.
Instructions:
- Build a 800Ã480 pygame window with a 32-pixel grid drawn as a faint blue line overlay. Number keys 1/2/3/4 select tool: platform (96Ã32 brown), spike (32Ã32 red), coin (24Ã24 gold), checkpoint (32Ã64 green). Use a
TOOLSdict keyed bypygame.K_1..K_4mapping to(kind, (w, h), color)tuples. - Implement
snap(v) = round(v / GRID) * GRIDâ round-to-nearest, NOT floor. A click at (49, 49) snaps to (64, 64) because 49 is closer to 64 than to 32. This is the critical contrast with chat-46 M2 tilemap'sworld_to_tilefloor (which DOES want floor: a world position at x=49 belongs to the cell spanning x=32..63). - Render a translucent outline preview at the snapped cursor position each frame â the user sees exactly where their click will land before clicking. Mouse-down places a real object: append
(kind, snap(mx), snap(my), w, h, color)toobjectslist, then callpush_history(). - Implement
push_history()using deep-copy snapshots:history = history[:history_index + 1]; history.append(deepcopy(objects)); history_index += 1. The truncation discards any redo tail when a new edit lands after an undo â history must always match the path of edits actually applied. Press Z to undo (decrement index, restore snapshot). - Press S to save: serialize
objectsto JSON via list comprehension[{'kind': k, 'x': x, ...} for ... in objects]dumped tolevel.json. Press V to validate: checkany(o[0] == 'checkpoint' for o in objects)and return a status message. Validation runs at DESIGN time (V key, save time) â not in the runtime gameplay loop.
đĄ Hint
Four patterns, four traps. (1) Use round not int or // for snapping â different operators for different intents (round-to-nearest for click-where-I-mean placement; floor for which-cell-contains-this-point lookup as in chat-46 M2 tilemap). (2) Use copy.deepcopy not list(objects) â shallow copy means mutating one snapshot mutates all. (3) Truncate the redo tail BEFORE appending the new snapshot, otherwise undo+new-edit followed by redo restores stale state. (4) Validation is a design-time discipline; calling validate() in the gameplay loop is wrong scope â by then the level is already loaded and the player is already stuck without a respawn.
â Example Solution
import pygame
import json
import sys
from copy import deepcopy
pygame.init()
SCREEN_W, SCREEN_H = 800, 480
GRID = 32
screen = pygame.display.set_mode((SCREEN_W, SCREEN_H))
pygame.display.set_caption("Mini Level Editor â Place, Snap, Undo, Save")
clock = pygame.time.Clock()
font = pygame.font.SysFont(None, 22)
# Tool palette: number key â (kind, (w, h), color)
TOOLS = {
pygame.K_1: ('platform', (96, 32), (130, 90, 50)),
pygame.K_2: ('spike', (32, 32), (200, 50, 50)),
pygame.K_3: ('coin', (24, 24), (255, 215, 0)),
pygame.K_4: ('checkpoint', (32, 64), ( 50, 200, 50)),
}
current_key = pygame.K_1
objects = [] # list of (kind, x, y, w, h, color)
history = [[]] # snapshot stack; history[0] = empty level
history_index = 0
status = "Click to place. Z=undo S=save V=validate 1/2/3/4=tool"
def snap(v):
"""Round-to-nearest â picks the closest grid line, NOT the floor.
Click at 49 snaps to 64 (49 is closer to 64 than to 32). Distinct from
chat-46 M2 world_to_tile floor: different operations for different intents."""
return round(v / GRID) * GRID
def push_history():
"""Snapshot-based undo. Truncate redo tail BEFORE appending so history
always matches the actual edit path applied to objects."""
global history, history_index
history = history[:history_index + 1]
history.append(deepcopy(objects))
history_index += 1
def undo():
"""Pointer walk + snapshot restore. No inverse logic per action type."""
global objects, history_index
if history_index > 0:
history_index -= 1
objects = deepcopy(history[history_index])
def save_json(path='level.json'):
"""Design data â portable JSON via to_dict round-trip pattern."""
with open(path, 'w') as f:
json.dump([{'kind': k, 'x': x, 'y': y, 'w': w, 'h': h}
for (k, x, y, w, h, _) in objects], f, indent=2)
return path
def validate():
"""Design-time invariant. Catches missing checkpoint BEFORE play, when
the cost of fixing is one click in the editor instead of a re-ship."""
if not any(o[0] == 'checkpoint' for o in objects):
return "MISSING: at least one checkpoint required"
return f"OK: {len(objects)} objects pass design-invariants"
while True:
clock.tick(60)
for e in pygame.event.get():
if e.type == pygame.QUIT:
pygame.quit(); sys.exit()
elif e.type == pygame.KEYDOWN:
if e.key in TOOLS:
current_key = e.key
status = f"Tool: {TOOLS[e.key][0]}"
elif e.key == pygame.K_z:
undo(); status = f"Undo: {len(objects)} obj, history@{history_index}"
elif e.key == pygame.K_s:
status = f"Saved {len(objects)} obj to {save_json()}"
elif e.key == pygame.K_v:
status = validate()
elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 1:
kind, (w, h), color = TOOLS[current_key]
x, y = snap(e.pos[0]), snap(e.pos[1])
objects.append((kind, x, y, w, h, color))
push_history()
status = f"Placed {kind} at ({x},{y}) history@{history_index}"
# Draw
screen.fill((28, 28, 40))
for gx in range(0, SCREEN_W, GRID):
pygame.draw.line(screen, (50, 50, 70), (gx, 0), (gx, SCREEN_H))
for gy in range(0, SCREEN_H, GRID):
pygame.draw.line(screen, (50, 50, 70), (0, gy), (SCREEN_W, gy))
for (kind, x, y, w, h, color) in objects:
pygame.draw.rect(screen, color, (x, y, w, h))
# Snapped cursor outline preview
mx, my = pygame.mouse.get_pos()
px, py = snap(mx), snap(my)
_, (w, h), color = TOOLS[current_key]
pygame.draw.rect(screen, color, (px, py, w, h), 2)
screen.blit(font.render(status, True, (240, 240, 240)), (10, SCREEN_H - 30))
pygame.display.flip()
đ¯ Quick Quiz
Question 1: The editor's snap(v) uses round(v / GRID) * GRID. The chat-46 M2 tilemap lesson's world_to_tile uses int(wx // tile_size) (floor division). A click at (49, 49) with GRID = 32 snaps to (64, 64) in this editor; the same world position belongs to tile (1, 1) (cell spanning 32..63) in tilemap. Why different operators for what looks like the same conversion?
Question 2: push_history() runs history = history[:history_index + 1] BEFORE appending the new snapshot. What goes wrong if you remove the truncation, and why was snapshot-based undo chosen over delta-based?
Question 3: validate() is bound to the V key (design-time call) and to save-time, NOT called in the runtime gameplay loop. The chat-46 M2 tilemap's is_solid out-of-bounds=True is a runtime invariant called every collision check. Why are these two correctness mechanisms placed at different lifecycle stages?
What's Next?
With powerful level design tools mastered, next we'll add visual depth with parallax scrolling to create immersive game worlds!