Skip to main content

2D Game Engine using pyglet (OpenGL) for rendering

Project description

PyVerse2D

A batteries-included 2D game engine for Python, built on top of pyglet.

PyVerse2D gives you a complete ECS-based runtime - physics, lighting, particles, tilemaps, GUI, audio, video - behind a clean and minimal API.

PyPI version Python License


Features

  • ECS architecture - entities, components, and systems with dependency/conflict validation
  • Physics engine - rigid bodies, gravity, collision detection and resolution (circles, capsules, polygons, ellipses, rounded rects)
  • Rendering pipeline - layered scenes, cameras with letterboxing, viewport transforms, z-ordering
  • Lighting system - ambient, point lights, cone lights, bloom, vignette, tint
  • Particle system - line, circle, cone and point emitters with modifiers (wind, drag, gravity, attractor)
  • Tilemap support - Tiled TMX loader, automatic collision injection, parallax cameras
  • GUI system - widgets, tweens, behaviors (click, hover, focus, select), toggle buttons, scrollbars, labels
  • Asset management - images, animations, fonts, sounds, music, playlists, video
  • Input system - keyboard, mouse, combo listeners, repeat and condition support
  • Post-processing - shader-based effects (blur, chromatic aberration, color grading, glitch, distortion, scanlines…) applied per zone with spatial masking
  • Built-in profiler - frame-accurate profiling with export

Installation

pip install pyverse2d

Or install the latest dev version directly from GitHub:

pip install https://github.com/WhiteWolf45380/PyVerse2D/archive/refs/heads/main.zip

Requirements: Python 3.11+, pyglet 2.x


Hello World

The minimal setup: open a window and draw a bouncing ball.

import pyverse2d as pv
from pyverse2d import Window, LogicalScreen, Camera, Viewport
from pyverse2d import world, scene

# --- Window ---
screen = LogicalScreen(1920, 1080)
window = Window(screen=screen, caption="Hello World", vsync=True)
pv.set_window(window)

# --- Scene ---
camera = Camera(anchor=(0.5, 0.5), view_width=40, view_height=22.5)
viewport = Viewport(width=1920, height=1080, origin=(0.5, 0.5))
main_scene = scene.Scene(camera=camera, viewport=viewport)
scene.push(main_scene)

main_world = world.World()
main_scene.add_layer(scene.WorldLayer(main_world), z=0)

# --- Systems ---
main_world.add_system(world.RenderSystem())
main_world.add_system(world.PhysicsSystem())
main_world.add_system(world.GravitySystem(pv.math.Vector(0.0, -9.8)))
main_world.add_system(world.CollisionSystem())

# --- Ground ---
ground_shape = pv.shape.Rect(30.0, 1.0)
ground = world.Entity(
    world.Transform(position=(0.0, -8.0), anchor=(0.5, 0.5)),
    world.ShapeRenderer(shape=ground_shape, filling_color=(80, 80, 80)),
    world.Collider(shape=ground_shape),
    world.RigidBody(restitution=0.5, friction=0.4),
)
main_world.add_entity(ground)

# --- Ball ---
ball_shape = pv.shape.Circle(1.0)
ball = world.Entity(
    world.Transform(position=(0.0, 8.0), anchor=(0.5, 0.5)),
    world.ShapeRenderer(shape=ball_shape, filling_color=(80, 180, 255)),
    world.Collider(shape=ball_shape),
    world.RigidBody(mass=1.0, restitution=0.8, friction=0.2),
)
main_world.add_entity(ball)

# --- Run ---
pv.preload()
pv.run()

Core concepts

Window & screen

LogicalScreen defines the virtual resolution your game is designed for. Window wraps the OS window and handles letterboxing automatically, your game scales cleanly to any physical window size.

screen = LogicalScreen(1920, 1080)   # virtual canvas
window = Window(screen=screen, resizable=True, vsync=True)
pv.set_window(window)

Scene & layers

A Scene is a self-contained game state. Layers stack inside a scene at a given z-index. WorldLayer, TileLayer, GuiLayer, LightLayer and ParticleLayer each handle a specific rendering domain.

main_scene = scene.Scene(camera=camera, viewport=viewport)
scene.push(main_scene)                                     # push onto the scene stack

main_scene.add_layer(scene.WorldLayer(main_world), z=0)    # ECS world
main_scene.add_layer(scene.GuiLayer(), z=100)              # UI on top

Entities & components

An Entity is a container of components. Components are plain data objects, logic lives in systems.

player = world.Entity(
    world.Transform(position=(0.0, 5.0), anchor=(0.5, 0.0)),
    world.ShapeRenderer(shape=pv.shape.Capsule(0.4, 2.0), filling_color=(255, 120, 60)),
    world.Collider(shape=pv.shape.Capsule(0.4, 2.0)),
    world.RigidBody(mass=60.0, friction=0.4),
    world.GroundSensor(),
)
main_world.add_entity(player)

Available components: Transform, ShapeRenderer, SpriteRenderer, TextRenderer, Animator, Collider, RigidBody, GroundSensor, Follow, SoundEmitter, VideoPlayer.

Systems

Systems process entities every frame. Add them to a World and they run in order:

main_world.add_system(world.RenderSystem())
main_world.add_system(world.PhysicsSystem())
main_world.add_system(world.GravitySystem(pv.math.Vector(0.0, -9.8)))
main_world.add_system(world.CollisionSystem())
main_world.add_system(world.AnimationSystem())
main_world.add_system(world.SteeringSystem())
main_world.add_system(world.SoundSystem(origin=camera))

Camera

Cameras define the point of view. They support smooth following, animated transitions, and parallax attachment.

# Follow a target with smooth lag
camera.follow(player.transform, offset=(0.0, 1.0), smoothing=0.05)

# Animated transition to a position
camera.goto((10.0, 0.0), duration=1.5, easing=pv.math.easing.ease_in_out_quad)

# Parallax-derived camera (e.g. for a background layer)
background_camera = Camera.derived_from(camera, parallax_x=0.3)

Physics

Entities with a Collider and a RigidBody participate in the physics simulation. Set gravity=False on a RigidBody to opt out of gravity. Use apply_force and apply_impulse for runtime interactions.

rb = entity.rigid_body
rb.apply_force(pv.math.Vector(500.0, 0.0))    # continuous force
rb.apply_impulse(pv.math.Vector(0.0, 300.0))  # instant impulse (e.g. jump)

Supported shapes: Circle, Rect, RoundedRect, Ellipse, Capsule, Polygon, RegularPolygon.

Inputs

# Single key listener
pv.inputs.add_listener(pv.key.K_SPACE, player.jump)

# Held key (repeat=True fires every frame)
pv.inputs.add_listener(pv.key.K_D, player.move_right, repeat=True)

# Combo
pv.inputs.when_all_of([pv.key.K_LEFT, pv.key.K_DOWN], move_downleft, repeat=True)

# Mouse
pv.inputs.add_listener(pv.mouse.B_LEFT, on_click)

Assets

# Image
img = pv.asset.Image("player.png", height=2.5)

# Animation from a folder (files matching a prefix)
anim = pv.asset.Animation.from_folder("assets/", prefix="run_", framerate=12, height=2.5)

# Sound with random variations
footstep = pv.asset.Sound.from_variations("assets/audio/", prefix="step_", cooldown=0.4)

# Music
music = pv.asset.Music("theme.ogg")
pv.audio.play_music(music, loop=True, fade_s=2.0)

Lighting

light_layer = pv.scene.LightLayer(
    pv.fx.Ambient(intensity=0.2, color=(0, 0, 30)),
    gamma=1.0,
    exposure=3.0,
)
main_scene.add_layer(light_layer, z=3)

# Point light attached to the player
point = pv.fx.PointLight(radius=10, intensity=1.0)
point.attach_to(player.transform, offset=(0.0, 1.0))
light_layer.add_source(point)

# Cone light (e.g. spotlight from above)
cone = pv.fx.ConeLight(position=(0.0, 30.0), direction=(0.0, -1.0), angle=20, intensity=1.2)
light_layer.add_source(cone)

Particles

particle_layer = pv.scene.ParticleLayer(additive=True)
main_scene.add_layer(particle_layer, z=-1)

particle = pv.fx.Particle(
    lifetime=(4, 8), speed=(5, 10),
    size=(0.3, 0.8), size_end=0.1,
    color_start=(0, 100, 255), color_end=(255, 255, 255),
)
emitter = pv.fx.LineEmitter(p1=(-50, 30), p2=(50, 30), normal=True, particle=particle, rate=80)
emitter.add_modifier(pv.fx.Wind(direction=-45, strength=1.5, turbulence=8))
particle_layer.add_emitter(emitter)

Tilemaps

# Load a Tiled .tmx file
stage = pv.tile.MapLoader.from_tiled_tmx("map/stage_0.tmx", tile_width=1.5, tile_height=1.5)

# Add layers to the scene
ground = stage["ground"]
main_scene.add_layer(pv.scene.TileLayer(ground), z=4)

# Inject tile colliders automatically into the world
pv.tile.CollisionMapper(ground).inject(main_world)

Post-processing

Post-processing effects are applied via a PostFxLayer. Each layer holds one or more PostFxZone objects, a zone can cover the entire screen (unbounded) or be restricted to a Circle or Rect shape with an optional soft blend edge. Effects chain in order and are GPU-accelerated (GLSL shaders).

postfx_layer = pv.scene.PostFxLayer()
main_scene.add_layer(postfx_layer, z=50)

# Unbounded zone: covers the full screen
zone = pv.fx.PostFxZone()
zone.add_effect(pv.fx.ColorGrade(brightness=0.05, contrast=1.1, saturation=0.9))
zone.add_effect(pv.fx.Vignette(strength=0.6, radius=80, softness=40))
postfx_layer.add_zone(zone)

# Bounded zone: circle around a point, with edge blending
local_zone = pv.fx.PostFxZone(shape=pv.shape.Circle(10), position=(0.0, 0.0), blend=3.0)
local_zone.add_effect(pv.fx.Blur(radius=4.0, passes=2))
postfx_layer.add_zone(local_zone)

# Attach a zone to a moving object
local_zone.attach_to(player.transform, offset=(0.0, 1.0), smoothing=0.05)

Available effects and their key parameters:

Effect Description Key params
Blur Gaussian blur (separable, multi-pass) radius, passes
Chromatic Chromatic aberration strength, angle
ColorGrade Brightness / contrast / saturation / tint brightness, contrast, saturation, tint
EdgeDetect Sobel edge detection threshold, strength, edge_color
Flicker Luminosity flicker (unstable light source) amplitude, speed
Glitch Digital corruption with band shifting strength, density, speed
Pixelate Pixelation block_size
Posterize Color level reduction (cel-shading) levels
Scanlines CRT scanline overlay spacing, strength, softness
Vignette Edge darkening strength, radius, softness, color
Wave Sinusoidal distortion (cartesian) amplitude_x, amplitude_y, frequency_x, frequency_y, speed
DistortSwirl Vortex rotation angle, falloff
DistortSqueeze Asymmetric directional stretch strength_x, strength_y, falloff
DistortRipple Radial concentric wave amplitude, frequency, speed, falloff

Effects can be added, removed, replaced and reordered at runtime:

zone.replace_effect(pv.fx.ColorGrade(saturation=0.0))   # swap params live
zone.move_effect(pv.fx.Blur, index=0)                   # push blur to first pass
zone.remove_effect(pv.fx.Vignette)
zone.disable()                                          # pause the zone without removing it

Profiling

pv.preload()
pv.profile(duration=10.0, on_update=on_update, export_path="profile_report.txt")

Game loop

def on_update(dt: float):
    # your update logic
    pass

def on_draw():
    # your extra draw logic (runs before scene.draw)
    pass

pv.preload()
pv.run(on_update=on_update, on_draw=on_draw)

pv.stop() cleanly shuts down the engine and closes the window.


Project structure

pyverse2d/
├── abc/           # Abstract base classes (Space, Component, System, Asset…)
├── asset/         # Image, Animation, Font, Sound, Music, Video, Text, Playlist
├── fx/
│   ├── light/     # Ambient, PointLight, ConeLight, Bloom, Vignette, Tint
│   ├── particle/  # Emitters (Line, Circle, Cone, Point) + Modifiers
│   └── postfx/    # PostFxZone, PostFxLayer + 14 shader effects
├── gui/           # Widgets, Tweens, Behaviors, SelectionGroup
├── math/          # Point, Vector, Line, easing functions, vertex helpers
├── scene/         # Scene, WorldLayer, TileLayer, GuiLayer, LightLayer, ParticleLayer, PostFxLayer
├── shape/         # Circle, Rect, RoundedRect, Ellipse, Capsule, Polygon, RegularPolygon
├── tile/          # Tiled TMX loader, CollisionMapper, TileMap
├── typing/        # Type aliases
└── world/
    ├── _component/ # Transform, Collider, RigidBody, Renderers, Animator, Follow…
    └── _system/    # Physics, Gravity, Collision, Render, Animation, Steering, Sound, Video

License

MIT - © WhiteWolf45380

Project details


Release history Release notifications | RSS feed

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyverse2d-1.5.10.tar.gz (273.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyverse2d-1.5.10-py3-none-any.whl (388.7 kB view details)

Uploaded Python 3

File details

Details for the file pyverse2d-1.5.10.tar.gz.

File metadata

  • Download URL: pyverse2d-1.5.10.tar.gz
  • Upload date:
  • Size: 273.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for pyverse2d-1.5.10.tar.gz
Algorithm Hash digest
SHA256 8704c08d1812ef60a44af0ac92402b27efea090c468649c672f6379ce84ae46e
MD5 9fdd1275d573c803e0a9d5c10ad9fc32
BLAKE2b-256 1e26ec038f411120807d6a29218dce1b153f477733af373c1a5be9d42e8f5d1d

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyverse2d-1.5.10.tar.gz:

Publisher: publish.yml on WhiteWolf45380/PyVerse2D

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pyverse2d-1.5.10-py3-none-any.whl.

File metadata

  • Download URL: pyverse2d-1.5.10-py3-none-any.whl
  • Upload date:
  • Size: 388.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for pyverse2d-1.5.10-py3-none-any.whl
Algorithm Hash digest
SHA256 3779b4ed9bf348790c216e54609dc17a4dc5a0e0c61de05305c1e7807bb9b80e
MD5 c52704240492068b52187b0981bf9db5
BLAKE2b-256 b66993c235898b86edd266821874ebc863b33f751bd608ce2acce42e35dd99df

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyverse2d-1.5.10-py3-none-any.whl:

Publisher: publish.yml on WhiteWolf45380/PyVerse2D

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page