A polished educational 2D physics sandbox for learning vectors, mechanics, momentum, and collision physics. Built with Python 3.12+, pygame-ce, and numpy.
Spawn circles and rectangles, drag and throw them, build pendulums, switch between Earth-style and planet (N-body) gravity, and toggle overlays that decompose every collision into the impulses and velocity components that drive it.
- Bodies — circles and AABB rectangles with mass, elasticity, friction, velocity, and (for circles) angular velocity / moment of inertia.
- Engine — semi-implicit Euler integrator with sub-stepping, configurable collision iterations, broad-phase uniform spatial grid, and Baumgarte position correction for stable stacks.
- Collisions — circle↔circle, circle↔rect, rect↔rect detection plus an impulse-based resolver with normal/friction impulses, restitution blending, and Coulomb-capped friction. Circles also pick up spin from lever-arm friction at the contact point.
- Constraints — two-body springs and anchored springs (Hooke + viscous damping), with a one-key pendulum spawn at the cursor.
- Two gravity modes — constant linear gravity (default), or N-body inverse-square planet gravity with softening to keep close encounters sane.
- Interaction — drag bodies with the mouse (they go kinematic and become infinite mass for the resolver); release with motion to throw at velocity sampled from the last 150 ms of cursor history.
- Stats — real-time FPS, body count, broad-phase candidate-pair count, contact count, total kinetic energy (linear + rotational), and total linear-momentum magnitude.
- Visualization — velocity / force / contact-normal arrows, position trails fading toward the background, collision spark particles, and an educational overlay that draws the resolved normal & friction impulse vectors at every contact alongside the pre-impulse approach speed.
- Modern dark theme — single
Themedataclass drives every color in the renderer and HUD.
The project is split into small, single-responsibility modules. Physics never imports rendering, and rendering never mutates physics state.
PhysicsLab/
├── main.py entry point
├── app.py Application — owns window, world, renderer,
│ hud, particles, main loop
├── config.py immutable config dataclasses (window /
│ simulation / physics / theme)
├── physics/
│ ├── vector.py Vector2 — immutable 2D vector primitive
│ ├── bounds.py Bounds — axis-aligned world boundary
│ ├── manifold.py Contact — single-point collision manifold
│ ├── collision.py pairwise detection + dispatcher
│ ├── resolver.py impulse resolution (linear + angular) +
│ │ positional correction
│ ├── broad_phase.py UniformGrid — spatial pair candidate
│ ├── constraints.py Spring + AnchoredSpring (Hooke + damping)
│ ├── particles.py ParticleSystem — spark emitter
│ ├── stats.py compute_stats(world) — KE / momentum
│ └── world.py World — engine, gravity modes, sub-stepping
├── entities/
│ ├── body.py abstract Body
│ ├── static_body.py StaticBody — fixed-position
│ ├── dynamic_body.py DynamicBody — linear + angular state +
│ │ semi-implicit Euler integrator
│ ├── circle_body.py CircleBody — overrides inverse_inertia
│ ├── rectangle_body.py RectangleBody — AABB (no rotation)
│ └── factory.py BodyFactory — palette-driven spawning
├── rendering/
│ ├── theme.py Theme dataclass — dark color palette
│ ├── vectors.py arrow-drawing helper for overlays
│ ├── trails.py TrailRecorder — per-body position history
│ └── renderer.py Renderer — draw pipeline, consumes a World
├── ui/
│ ├── fonts.py FontCache — lazy-loaded system fonts
│ ├── hud.py Hud — stats panel, controls hint, banners
│ └── interaction.py InteractionController — drag/throw/spawn
└── utils/
└── clock.py FrameClock — pygame clock + smoothed FPS
- Physics is pure.
physics/andentities/import nothing from pygame. - Rendering is read-only.
Rendererconsumes aWorld; it never mutates it. - UI is a layer. The
Huddraws on top of the rendered world; the world has no awareness of it. - Config is immutable. All config objects are frozen dataclasses with slots.
- Type hints everywhere.
from __future__ import annotationsis used uniformly so forward references stay clean. Vector2operations don't silently broadcast.vec + 5raisesTypeError; the math reads exactly as it's intended.
Requires Python 3.12 or newer.
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS / Linux
source .venv/bin/activate
pip install -r requirements.txt
python main.pyThe window opens at 1280×720, is resizable, runs capped at 60 FPS.
| Input | Action |
|---|---|
| Left click | On a body → drag it; on empty space → spawn a circle |
| Left release | Release a dragged body; sustained motion → throw |
| Right click | Spawn a rectangle |
P |
Spawn a pendulum (anchored spring) at the cursor |
Space |
Pause / resume the simulation |
R |
Reset — clears all bodies, constraints, trails, sparks |
G |
Toggle linear gravity on / off |
O |
Toggle planet (inverse-square) gravity mode |
Tab |
Toggle 0.25× slow-motion |
V |
Toggle velocity / force / contact-normal arrows |
T |
Toggle motion trails behind dynamic bodies |
E |
Toggle educational overlay (impulse decomposition) |
Esc |
Quit |
The World is the engine. Each frame's dt is clamped to max_dt and
divided into substeps sub-steps. Per sub-step:
- Apply pair-wise planet gravity (if
gravity_mode == PLANET). - Apply spring / pendulum constraints (Hooke + damping).
- For each dynamic body: add linear gravity, linear damping, angular damping; integrate position & orientation via semi-implicit Euler.
- Broad-phase candidates via the uniform spatial grid → narrow-phase
detect → resolver, repeated
collision_iterationstimes for stable stacks. - Resolve world-boundary collisions (clamp + reflect + tangential friction).
Three pairwise detectors live in physics/collision.py — circle–circle,
AABB–AABB, and circle–AABB — plus a detect() dispatcher that preserves
the caller's (A, B) ordering and flips the contact normal when the
shape order is reversed. Every contact carries one point, a unit normal
pointing A → B, and a positive penetration depth.
Resolution lives in physics/resolver.py:
| Step | Math |
|---|---|
| Approach test | v_n = (v_b_at_p − v_a_at_p) · n ; if v_n > 0 → only correct overlap |
| Effective mass | m⁻¹_eff = 1/m_a + 1/m_b + (r_a×n)²/I_a + (r_b×n)²/I_b |
| Normal impulse | j = −(1+e) · v_n / m⁻¹_eff |
| Friction (tangent) | j_t = −v_rel·t / m⁻¹_eff_t, capped at μ · j |
| Coefficient blending | e = min(e_a, e_b), μ = √(μ_a · μ_b) |
| Positional correction | Δp = max(d − slop, 0) · % · n |
Both normal and tangent impulses also feed Δω = (r×J) · I⁻¹ into each
body's angular velocity, so glancing collisions spin circles correctly.
Static bodies are modelled by inverse_mass = 0; RectangleBody.inverse_inertia = 0 keeps rectangles non-rotating (their AABB collision shape would be a
lie under spin until SAT is implemented).
physics/constraints.py defines a Constraint protocol with apply()
and endpoints(). Two implementations:
Spring(body_a, body_b, rest_length, stiffness, damping)— two-body damped Hooke:F = (k · ext + c · v_radial)alongA → B.AnchoredSpring(body, anchor, rest_length, stiffness, damping)— same math, but one end is a fixed world-space point.Pspawns one of these as an instant pendulum.
Constraints apply via apply_force, so spring tension flows through the
same acceleration accumulator as gravity — students see Hooke's law
acting on the integrator, not hidden behind a positional solver.
| Mode | Force |
|---|---|
LINEAR |
constant m · g_vec on every dynamic body |
PLANET |
pair-wise G · m_a · m_b / max(r, softening)² along A → B |
Total linear momentum is conserved in PLANET mode by Newton's third law.
Vector2 is the foundational geometry primitive: positions, velocities,
forces, normals, and impulses are all Vector2 instances. It's an
immutable frozen dataclass — operations return new instances, which
keeps integration and collision math free of aliasing bugs.
import math
from physics import Vector2
position = Vector2(100.0, 200.0)
velocity = Vector2.from_angle(math.pi / 4, magnitude=50.0)
gravity = Vector2(0.0, 980.0)
new_pos = position + velocity * dt # operator overloaded
speed = velocity.magnitude
unit = velocity.normalized()
turn = velocity.cross(target_velocity) # signed: + CCW, − CW
normal = surface.perpendicular()
bounced = velocity.reflected(normal)python -m unittest discover testsThe tests/ suite covers Vector2 (construction, arithmetic, geometry,
type guards); body integration; collision detection for all three shape
pairs; impulse resolution including momentum & energy conservation
invariants; rotation (inertia, friction-induced spin, head-on no-spin);
the broad-phase grid (dedup, cell-spanning bodies, mixed shapes);
constraints; world stats; trails; particles; planet gravity (inverse
square, softening, kinematic exclusion, mode switching); the educational
overlay's impulse-capture; and the interaction system (hit-test, drag,
throw cap, kinematic invariants).
All ten parts of the development roadmap are complete: foundation → vector math → physics bodies → engine core → collision system → interaction → stats & visualization → optimization → advanced physics (springs, pendulum, rotation, planet gravity, particles) → educational overlays + polish.