pydamics
A small, chainable-syntax 2D physics engine for Python. 3D support planned.
Install
pip install pydamics # once published to PyPI
# or, from source:
pip install -e .
Usage
pydamics works two ways. Use whichever fits your project.
1. With the built-in Entity class
from pydamics import Entity, World
ball = Entity(mass=2.0, position=(0, 10))
ball.physics2d.gravity(force=9.8)
ball.physics2d.fluid(density=1.2, drag=0.3)
world = World()
world.add(ball)
# Option 1: step it yourself
for _ in range(120):
world.step(dt=1/60)
print(ball.position)
# Option 2: let the engine run itself on a background thread
world.run(dt=1/60)
...
world.stop()
2. As an extension on YOUR OWN class
pydamics doesn't force an Entity/World object model on you. If you already have your own classes, three equivalent ways to make an object physics-capable -- pick whichever fits how you write your classes:
import pydamics
from pydamics import World
class Spaceship:
def __init__(self, name):
self.name = name # your own attributes, untouched
# (a) function call -- no inheritance required
ship = pydamics.attach(Spaceship("Falcon"), mass=1500.0, position=(0, 20))
# (b) mixin -- inherit and call super().__init__()
class Spaceship(pydamics.PhysicsObject):
def __init__(self, name, **physics_kwargs):
super().__init__(**physics_kwargs)
self.name = name
ship = Spaceship("Falcon", mass=1500.0, position=(0, 20))
# (c) decorator -- no inheritance, no manual call
@pydamics.physics_class(mass=1500.0, position=(0, 20))
class Spaceship:
def __init__(self, name):
self.name = name
ship = Spaceship("Falcon")
ship.physics2d.gravity(force=9.8)
world = World()
world.add(ship) # World.add() checks pydamics.has_physics(ship) and
# raises a clear TypeError if you forgot to attach()
world.step(dt=1/60)
Entity is just a thin convenience wrapper around attach() -- use
whichever suits how you're structuring your project.
3. One unified entry point: classify() + kind_of()
attach()/solidify()/fluidify() are three different verbs to
remember. classify() is a thin dispatcher over all three -- pick a
kind (or a list of them) instead:
import pydamics
from pydamics import World
pydamics.classify(ship, kind="rigid", mass=1500.0, position=(0, 20))
pydamics.classify(platform, kind=["rigid", "solid"], mass=50.0, position=(0, 0)) # both at once
pydamics.classify(droplet, kind="fluid", mass=1.0, position=(0, 5))
pydamics.kind_of(ship) # -> frozenset({"rigid"})
It works as a plain call (classification already happened by the time
you get the return value) or as a with-block for grouping setup
visually -- __enter__ just hands back the object itself:
with pydamics.classify(platform, kind=["rigid", "solid"], mass=50.0, position=(0, 0)) as cfg:
cfg.physics2d.mass(9).velocity(0, 0)
cfg.seo.solid(width=8, height=1)
Passing a property that doesn't apply to the requested kind raises a
clear error instead of silently doing nothing -- e.g. mass= with
kind="solid" alone (no "rigid") raises TypeError, since a pure
solid never gets a .physics2d namespace or gets integrated by
world.step().
classify()/kind_of() don't replace attach()/solidify()/
fluidify() -- those work exactly as before; classify() is additive
sugar on top.
Attachable forces (obj.physics2d)
| Method | Description |
|---|---|
.gravity(force=9.8, direction=None) |
Constant acceleration in a direction (default: down) |
.fluid(density=1.0, drag=0.1) |
Velocity-proportional drag (air/water resistance) |
.friction(coefficient=0.3, normal_force=9.8) |
Kinetic friction opposing motion |
.spring(anchor, stiffness=10.0, rest_length=1.0, damping=0.1) |
Hooke's-law spring toward a point or another physics object (anchor can be moving) |
.wind(force=2.0, direction=None, gust=0.0) |
Constant directional acceleration, optionally gusting |
.attractor(target, strength=50.0, min_distance=0.1) |
Inverse-square pull toward a point/object (orbital-style gravity) |
.vortex(center, strength=20.0, min_distance=0.1) |
Tangential swirling force around a point |
.buoyancy(zone, radius=0.4, gravity=9.8) |
Archimedes-style float/sink force inside a FluidZone |
.gas(zone) |
Constant x-only push inside a GasZone -- deliberately minimal (no drag/gust/y) |
.custom(force) |
Attach your own Force subclass |
.remove(force) |
Detach a previously attached force |
.clear() |
Detach all forces |
Every attach method returns the Force object, so you can hold onto it and
remove/tweak it later:
g = ball.physics2d.gravity(force=9.8)
ball.physics2d.remove(g)
Chainable setters
Update state after construction -- each returns self so they stack:
ball.physics2d.mass(9).velocity(0, 0).position(3, 4)
| Method | Description |
|---|---|
.mass(value) |
Update mass |
.position(x, y) |
Update position |
.velocity(x, y) |
Update velocity |
.restitution(value) |
Update collider bounciness -- requires .collider() already called, raises RuntimeError otherwise |
.radius(value) |
Update collider size -- same requirement |
.static(bool) |
Toggle whether a collider is static -- same requirement |
Collision
ball.physics2d.collider(radius=0.4, restitution=0.7) # bouncy
wall.physics2d.collider(radius=0.5, restitution=0.5, static=True) # never moves
World.step() automatically detects and resolves overlaps between any
entities that have a .physics2d.collider(...) -- impulse-based, with a
restitution (bounciness) you set per object; the lower of the two
objects' restitution values is used per collision.
Box colliders
crate.physics2d.collider(shape="box", width=1.5, height=1.5, restitution=0.3)
Oriented (rotated) boxes, not just axis-aligned -- a box collider follows
the entity's .angle, so spin it like anything else (torque, an
off-center hit, .angle/.angular_velocity directly) and collision keeps
working correctly. Uses SAT (separating axis theorem) under the hood.
Every combination works: box-vs-box, box-vs-circle, and box-vs-SEO-solid
(box or circle), including corner-only contact between two rotated boxes.
Layers and masks
Filter what collides with what:
bullet.physics2d.collider(radius=0.1, layer="player_bullet", collides_with={"enemy"})
enemy.physics2d.collider(radius=0.5, layer="enemy")
wall.physics2d.collider(radius=1.0, layer="wall")
collides_with=None (the default) collides with everything regardless
of layer. When set, filtering is symmetric-AND: a pair only collides if
each side's collides_with (when set) includes the other's layer —
so the bullet above hits the enemy but passes straight through the
wall. .seo.solid() takes the same layer/collides_with kwargs.
Collision events
world.on_collision(lambda a, b, point, normal, impulse: print(f"{a} hit {b}"))
ball.physics2d.on_collision(lambda other, point, normal, impulse: print("I got hit"))
Fires once per step for every collision actually resolved (entity-entity
or entity-vs-solid). world.on_collision's normal points from a to
b; the per-object .physics2d.on_collision gets a normal pointing
away from the other object (i.e. "the direction I got pushed").
Raycasting
"What's the nearest thing along this line?" -- for line-of-sight checks, click-to-select, laser/projectile logic:
hit = world.raycast(origin=(0, 0), direction=Vec2(1, 0), max_distance=50)
if hit:
print(hit.entity, hit.point, hit.distance, hit.normal)
hits = world.raycast_all(origin=(0, 0), direction=Vec2(1, 0), max_distance=50)
# every hit along the ray, nearest first
Works against both entities and SEO solids, circle or box shapes
(respecting rotation), and takes an optional collides_with set for the
same layer filtering as collision. direction doesn't need to be
normalized. Pathfinding built on repeated raycasts is out of scope here
-- that's app/AI-layer logic, not physics.
Spatial queries
"Give me everything within this radius/rect" -- for AOE damage, aggro range, minimap/radar logic:
nearby = world.query_radius(center=(0, 0), radius=50)
in_box = world.query_rect(min_point=(0, 0), max_point=(100, 100))
Checks entity .position against the region (not collider-shape-aware)
-- matches the simple "who's nearby" check most of this kind of logic
actually wants. Uses the same spatial hash as collision broad-phase.
Trigger / sensor zones
Overlap detection with on_enter/on_exit callbacks, but no collision
response — nothing bounces off a trigger. Good for checkpoints, pickups,
aggro radii, damage zones:
zone = pydamics.TriggerZone(position=(10, 0), radius=2.0,
on_enter=lambda e: print("entered!"),
on_exit=lambda e: print("left!"))
world.add_trigger(zone)
Pass radius for a circular zone or width+height for a rectangular
one. Entities are checked against their .position (treated as a
point, ignoring any collider radius) — matches the simple "is this
point inside this zone" check most games actually want. Callbacks fire
once per transition, not every frame while inside/outside; if an entity
is already inside on the first check (e.g. it spawned there), on_enter
fires then.
Sleep / deactivation
Skip integrating objects that have settled, for performance:
ball.physics2d.sleep_threshold = 0.05 # velocity below this -> eligible to sleep
ball.physics2d.is_sleeping # read-only
ball.physics2d.wake() # force it awake immediately
sleep_threshold defaults to None (sleeping disabled) — opt-in only,
so nothing changes unless you set it. Once velocity stays below the
threshold for half a second, the object stops getting force-computed
and integrated entirely, but still participates in collision detection
— a moving object hitting a sleeping one wakes it automatically. Set
sleep_threshold = None again to disable sleep and wake it.
Orientation — angle, angular velocity, torque
Every Entity/attach()-ed object has rotational state alongside its
linear position/velocity, integrated with the same velocity-Verlet
scheme:
ball = Entity(mass=1.0, position=(0, 10), angle=0.0, angular_velocity=0.0,
moment_of_inertia=None) # defaults to mass * 0.5
ball.physics2d.torque(magnitude=5.0) # steady torque, returns a Torque you can remove
ball.physics2d.remove_torque(t)
Defaults are a complete no-op — angle/angular_velocity never change
unless you apply torque or an off-center collision imparts spin. That
second part only actually happens for box solids, not circles: since
the collision normal is always computed from the contact point toward a
circle's own center, a circular mover's lever arm is always exactly
parallel to its own impulse (cross product is provably always zero) —
matching real frictionless sphere physics (no tangential/friction impulse
is modeled here). A physicsified box hit away from its center, though,
picks up genuine torque, since a box's geometry isn't radially symmetric.
SEO — Solid Environment Objects
For solid geometry (platforms, walls, floors) that things collide with,
.seo works whether or not the object is also physics-capable:
import pydamics
# a plain object, made purely static/solid -- doesn't need attach()
class Platform:
pass
platform = Platform()
pydamics.solidify(platform, position=(0, 0))
platform.seo.solid(width=8, height=1, restitution=0.4)
world.add_solid(platform) # register it for collision (not world.add() --
# it isn't physics-capable, so world.add()
# would reject it)
If the object is ALSO physics-capable (attach()-ed or an Entity), it
becomes a "physicsified" solid: movable/affected by forces, but still
solid -- e.g. a platform that falls under gravity but still carries a
ball resting on top of it. Physicsified solids just go through the
normal world.add() -- they're auto-detected as solids too, no need to
also call add_solid().
platform = pydamics.attach(Platform(), mass=50.0, position=(0, 10))
platform.physics2d.gravity(force=2.0)
pydamics.solidify(platform) # reuses the position attach() set
platform.seo.solid(width=8, height=1)
world.add(platform) # physics-capable -> world.add(), not add_solid()
.seo.solid() accepts either width+height (rectangle) or radius
(circle). Like physics attachment, solidify() has mixin/decorator
equivalents too -- pydamics.SolidObject (inherit + super().__init__())
and @pydamics.solid_class(position=...).
Fluid dynamics
Two different scopes, depending on what you need:
FluidZone (buoyancy) — lightweight: a rectangular region entities
float or sink in, via .physics2d.buoyancy(zone) (see the forces table
above). density is relative to your entities' own effective density
(mass / (pi * radius^2)) — not a literal real-world kg/m³ value; pick
values relative to what your entities' mass/radius actually imply, or
you'll get correctly-extreme (but probably undesired) results, the same
way a helium balloon dropped in water would rocket upward in real life.
pool = pydamics.FluidZone(min_point=(-5, 0), max_point=(5, 5), density=1.8, drag=1.5)
cork.physics2d.buoyancy(zone=pool, radius=0.3)
GasZone (minimal air push) — deliberately much simpler than
FluidZone: no buoyancy, no drag, no gust, no y-component or direction
vector. Just a constant push along x for anything inside, via
.physics2d.gas(zone). Requesting kind="gas" through classify()
also gives the object .physics2d (a "rigid" classification comes
along with it), since the push is a Force like any other:
wind_tunnel = pydamics.GasZone(min_point=(-10, -10), max_point=(10, 10), force=5.0)
puff = pydamics.classify(MyParticle(), kind="gas", mass=0.2, position=(0, 0)).obj
puff.physics2d.gas(wind_tunnel)
FluidSystem (full SPH) — real smoothed-particle-hydrodynamics: particles
with density/pressure/viscosity computed from their neighbors, genuinely
fluid-like behavior. Its own particle system (not the Entity/physics2d
model, since SPH forces are inherently pairwise), and uses a spatial hash
internally so it scales past a few hundred particles:
from pydamics import FluidSystem, Vec2
fluid = FluidSystem(smoothing_radius=1.0, rest_density=1000.0, stiffness=150.0)
fluid.add_particle(position=(0, 5)) # built-in particle
# ... add more particles ...
world.add_fluid_system(fluid, gravity=9.8) # steps alongside world.step()
# or drive it yourself:
fluid.step(dt=1/120, gravity=9.8)
fluid.apply_bounds(Vec2(-5, 0), Vec2(5, 10)) # optional container walls
Using your own class as a fluid particle — mirrors attach()/solidify():
class WaterDroplet:
def __init__(self, name):
self.name = name
droplet = pydamics.fluidify(WaterDroplet("drop1"), mass=1.0, position=(0, 5))
pydamics.is_fluid(droplet) # True -- check whether something's fluid-capable
fluid.add(droplet) # register it directly (fluid.add_particle() only
# makes built-in FluidParticle instances)
Also has mixin (pydamics.FluidObject) and decorator (@pydamics.fluid_class(...))
equivalents, same pattern as physics/SEO.
Performance
Both collision (entity-entity) and SPH neighbor search use a uniform
grid spatial hash internally instead of a naive O(n²) scan — roughly
O(n) for reasonably spread-out scenes instead of quadratic. This is an
implementation detail, not an API change; pydamics.SpatialHash is
exposed if you want it for your own pairwise-interaction code.
Integration
Uses Velocity Verlet integration (not simple Euler, not full RK4) — it's the standard for force-based particle sims: stable, and integrates naturally with drag and collision impulses.
Tests
pip install -e ".[dev]"
pytest tests/
Visualization
Rendering (matplotlib GIFs, interactive pygame windows) lives in a separate companion package so this core library stays dependency-free:
pip install pydamicsvisual
See pydamicsvisual for details.
Roadmap
Further out:
- 3D physics namespace (
entity.physics3d) - Polygon collision shapes beyond boxes; capsule colliders (deliberately cut from v0.5.1 -- not as well-specified as boxes were)
- Joints/constraints (pin, distance, fixed, rope) -- likely its own v0.6.0, this is a bigger undertaking than anything above (iterative constraint solver)
- Tangential/friction impulses (would let circular movers pick up spin from off-center contact, not just boxes)
- Pathfinding built on raycasting (deliberately out of scope for the physics engine itself -- app/AI-layer logic)
Publishing (for maintainers)
1. Push to GitHub
git init
git add .
git commit -m "Initial commit: pydamics 2D physics engine"
git branch -M main
git remote add origin https://github.com/<your-username>/pydamics.git
git push -u origin main
The .github/workflows/tests.yml workflow will auto-run the test suite on
every push.
2. One-time PyPI setup (Trusted Publishing — no API tokens needed)
- Create a PyPI account if you don't have one.
- Go to pypi.org → Your account → Publishing and add a new "trusted publisher":
- PyPI project name:
pydamics - Owner:
<your-github-username> - Repository name:
pydamics - Workflow name:
publish.yml - Environment name:
pypi
- PyPI project name:
- In your GitHub repo, go to Settings → Environments and create an environment named
pypi(this matches the workflow file — no secrets needed, trusted publishing handles auth).
3. Ship a release
Bump the version in pyproject.toml, commit, then on GitHub:
Releases → Draft a new release → tag v0.1.0 → Publish release.
That triggers .github/workflows/publish.yml, which builds the package and
uploads it to PyPI automatically. From then on, anyone can:
pip install pydamics
License
Kiko Python Software Studio License (MIT-based, with additional usage terms) -- see LICENSE. In short: free to use, modify, and ship in your own commercial or non-commercial projects, but credit pydamics/Kiko Python Software Studio somewhere reasonable (a README/about/credits screen), don't claim you authored the engine itself, and don't use it to build malicious or NSFW content. This is not the plain MIT License and isn't OSI-approved open source, due to the added restrictions -- see the LICENSE file for the exact terms.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pydamics-0.5.1.tar.gz.
File metadata
- Download URL: pydamics-0.5.1.tar.gz
- Upload date:
- Size: 59.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6e50ab0bf7a9e13d184f9e2cdba2e20a9f59c0475f4d6162260ee10e3475cef8
|
|
| MD5 |
b35f3fd7981380ac659494c8f12fd68a
|
|
| BLAKE2b-256 |
6d8d286290fc0dc1f57a36957ac474563c35eb47078837ee276c10fe42df9fe9
|
Provenance
The following attestation bundles were made for pydamics-0.5.1.tar.gz:
Publisher:
publish.yml on Reeed-cell/Pydamics
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pydamics-0.5.1.tar.gz -
Subject digest:
6e50ab0bf7a9e13d184f9e2cdba2e20a9f59c0475f4d6162260ee10e3475cef8 - Sigstore transparency entry: 2358234795
- Sigstore integration time:
-
Permalink:
Reeed-cell/Pydamics@9510aee0c1f780bbf1ac5ebd123fb61b53da3e68 -
Branch / Tag:
refs/tags/v0.5.1 - Owner: https://github.com/Reeed-cell
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@9510aee0c1f780bbf1ac5ebd123fb61b53da3e68 -
Trigger Event:
release
-
Statement type:
File details
Details for the file pydamics-0.5.1-py3-none-any.whl.
File metadata
- Download URL: pydamics-0.5.1-py3-none-any.whl
- Upload date:
- Size: 49.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
094871382cff0ef50bae6b55b06b3ef803dbec91aa3f7ad9d6cb70f09c5b2620
|
|
| MD5 |
ae264d6d3090027259e1452018af38ae
|
|
| BLAKE2b-256 |
2b16da65a5a654a2571133eaa89e88810a3772430a3c6db24fc87d611c3e9242
|
Provenance
The following attestation bundles were made for pydamics-0.5.1-py3-none-any.whl:
Publisher:
publish.yml on Reeed-cell/Pydamics
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pydamics-0.5.1-py3-none-any.whl -
Subject digest:
094871382cff0ef50bae6b55b06b3ef803dbec91aa3f7ad9d6cb70f09c5b2620 - Sigstore transparency entry: 2358234843
- Sigstore integration time:
-
Permalink:
Reeed-cell/Pydamics@9510aee0c1f780bbf1ac5ebd123fb61b53da3e68 -
Branch / Tag:
refs/tags/v0.5.1 - Owner: https://github.com/Reeed-cell
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@9510aee0c1f780bbf1ac5ebd123fb61b53da3e68 -
Trigger Event:
release
-
Statement type: