Skip to main content

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:

graph TD A["Level Editor"] --> B["Object System"] A --> C["Entity System"] A --> D["Tools"] B --> E["Tiles/Blocks"] B --> F["Decorations"] B --> G["Collision"] C --> H["Enemies"] C --> I["Pickups"] C --> J["Triggers"] D --> K["Selection"] D --> L["Brush Tools"] D --> M["Path Editor"] N["Features"] --> O["Undo/Redo"] N --> P["Copy/Paste"] N --> Q["Test Mode"]

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

Key Takeaways

đŸ‹ī¸â€â™‚ī¸ 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:

  1. 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 TOOLS dict keyed by pygame.K_1..K_4 mapping to (kind, (w, h), color) tuples.
  2. 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's world_to_tile floor (which DOES want floor: a world position at x=49 belongs to the cell spanning x=32..63).
  3. 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) to objects list, then call push_history().
  4. 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).
  5. Press S to save: serialize objects to JSON via list comprehension [{'kind': k, 'x': x, ...} for ... in objects] dumped to level.json. Press V to validate: check any(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!