Table of Contents
- Physics Engine
- Overview
- Core Concepts
- PhysProperties
- ActorWithBody Physics
- Collision Detection
- Mass and Momentum
- Gravity System
- Friction
- Advanced Physics
- Collision Response
- Performance Optimisations
- Common Patterns
- Multi-Threading
- Debug Visualisation
- Best Practises
- Limitations
- Future Enhancements
- See Also
Physics Engine
Terrarum features a custom AABB-based physics system optimised for handling millions of tile bodies alongside dynamic actors. The system simulates Newtonian motion, collision detection, and gravitational forces.
Overview
The physics engine provides:
- AABB collision detection — Axis-Aligned Bounding Box system
- Tile-based optimisation — Efficient handling of millions of static blocks
- Newtonian simulation — Velocity, acceleration, momentum
- Gravity system — Configurable gravitational acceleration
- Friction and damping — Surface and air resistance
- Multi-threading support — Scalable actor updates (currently disabled for performance)
Core Concepts
Units and Scale
METER Constant:
companion object {
const val METER = 25.0 // 1 metre = 25 pixels
}
This fundamental constant defines the relationship between pixels and physics simulation:
- 1 metre = 25 pixels
- 1 tile = 16 pixels = 0.64 metres
Time Step
PHYS_TIME_FRAME (from Terrarum.kt):
const val PHYS_TIME_FRAME: Double = METER // = 25.0
The physics time step is set to match METER, creating a 1:1 correspondence between game units and SI units in calculations.
Frame Rate:
Velocity and acceleration scale with delta time to maintain consistent physics at any frame rate. The physics simulation assumes a reference of 60 FPS, with values scaling proportionally:
// Velocity units: pixels per 1/60 seconds
// When resolving velocity, account for actual framerate:
// v_resolved = v * (60 * delta) where delta is frame time in seconds
PhysProperties
Physical objects have properties defining their physics behaviour:
class PhysProperties(
var immobile: Boolean = false,
var usePhysics: Boolean = true,
var movementType: Int = PhysicalTypes.NORMAL,
// ... more properties
)
Common Presets
PhysProperties.HUMANOID_DEFAULT() // Standard character physics
PhysProperties.FLYING() // Flying entities
PhysProperties.PROJECTILE() // Bullets, arrows
PhysProperties.STATIONARY() // Fixed objects
Movement Types
object PhysicalTypes {
const val NORMAL = 0 // Standard gravity physics
const val FLYING = 1 // No gravity, free movement
const val RIDING = 2 // Mounted on vehicle
const val SWIMMING = 3 // Fluid dynamics
}
ActorWithBody Physics
Actors inheriting from ActorWithBody have full physics simulation.
Velocity System
ActorWithBody maintains two velocity vectors:
External Velocity
Physics-driven movement (gravity, collisions, forces):
internal val externalV: Vector2 // pixels per frame at 60 FPS
Controller Velocity
Player/AI-controlled movement:
var controllerV: Vector2? // Only for Controllable actors
Applying Forces
Add acceleration to velocity:
// Apply gravity (delta is frame time, 60.0 is reference FPS)
externalV.y += world.gravitation.y * delta * 60.0
// Apply horizontal force
externalV.x += pushForce
// Apply damping
externalV.scl(dampingFactor)
Velocity Limits
private val VELO_HARD_LIMIT = 100.0 // Maximum velocity magnitude
// Clamp velocity
if (externalV.len() > VELO_HARD_LIMIT) {
externalV.nor().scl(VELO_HARD_LIMIT)
}
Collision Detection
AABB System
All collision uses Axis-Aligned Bounding Boxes:
class Hitbox(
var startX: Double, // Left edge
var startY: Double, // Top edge
var width: Double, // Width
var height: Double // Height
) {
val endX: Double get() = startX + width
val endY: Double get() = startY + height
val centerX: Double get() = startX + width / 2
val centerY: Double get() = startY + height / 2
}
Hitbox Queries
fun containsPoint(x: Double, y: Double): Boolean
fun intersects(other: Hitbox): Boolean
fun overlaps(x: Double, y: Double, width: Double, height: Double): Boolean
Tile Collision
Half-Integer Tilewise Hitbox
For physics collision with tiles:
val hIntTilewiseHitbox: Hitbox
This hitbox represents the actor's position in "half-integer" tile coordinates, aligned to tile centres for physics calculations.
Integer Tilewise Hitbox
For block occupation checks:
val intTilewiseHitbox: Hitbox
This represents which tiles the actor fully or partially occupies.
Collision Iteration
Check collision with surrounding tiles:
for (tx in hIntTilewiseHitbox.startX.toInt()..hIntTilewiseHitbox.endX.toInt()) {
for (ty in hIntTilewiseHitbox.startY.toInt()..hIntTilewiseHitbox.endY.toInt()) {
val block = world.getTileFromTerrain(tx, ty)
if (BlockCodex[block]?.isSolid == true) {
// Handle collision
}
}
}
Mass and Momentum
Mass Calculation
Actor mass scales with volume (cubic scaling):
val mass: Double = baseMass * scale³
Where:
- baseMass — Mass at scale = 1.0 (in kilograms)
- scale — Actor's size multiplier
Momentum
val momentum: Vector2 = velocity * mass
Larger, heavier actors have more momentum and are harder to stop.
Gravity System
World Gravity
Each world defines gravitational acceleration:
var gravitation: Vector2 = DEFAULT_GRAVITATION
Default gravity approximates Earth's 9.8 m/s²:
// Typical value (not a defined constant in codebase):
// val DEFAULT_GRAVITATION = Vector2(0.0, 9.8 * METER / (60.0 * 60.0))
// This converts 9.8 m/s² to game units (pixels per frame²)
Applying Gravity
Gravity is applied each physics update:
if (physProp.usePhysics && physProp.movementType == PhysicalTypes.NORMAL) {
externalV.add(world.gravitation * delta * 60.0) // delta = frame time in seconds
}
Gravity Exceptions
- Flying actors (movementType = FLYING) — No gravity
- Immobile actors (immobile = true) — No movement
- Riding actors (movementType = RIDING) — Follow mount's motion
Friction
Surface Friction
Blocks define horizontal friction:
val frictionCoeff: Float // <16: slippery, 16: normal, >16: sticky
Friction is applied when actors touch ground:
if (onGround) {
val friction = groundBlock.frictionCoeff / 16.0
controllerV.x *= friction
}
Vertical Friction
For wall sliding:
val verticalFriction: Float // 0 = not slideable
Applied when actor is against a wall:
if (againstWall && block.verticalFriction > 0) {
externalV.y *= block.verticalFriction
}
Air Resistance
Damping applied to all movement:
val airDamping = 0.99 // Slight damping
externalV.scl(airDamping)
controllerV?.scl(airDamping)
Advanced Physics
Platform Collision
Platforms are special blocks:
val isPlatform: Boolean // One-way collision
Actors can jump through from below but land on top:
if (block.isPlatform && actor.externalV.y <= 0 && actor.bottomY <= blockTopY) {
// Land on platform
} else if (actor.externalV.y > 0) {
// Pass through from below
}
Falling Blocks
Blocks can fall due to gravity:
val gravitation: Int?
// null: Don't fall
// 0: Fall immediately (sand)
// N: Support N floating blocks (scaffolding)
Falling blocks become temporary actors and settle when they hit solid ground.
Fluid Dynamics
Actors in fluids experience:
- Buoyancy — Upward force based on density difference
- Viscosity — Movement resistance
- Drag — Velocity-dependent resistance
if (inFluid) {
val fluid = FluidCodex[fluidID]
val buoyancy = (fluid.density - actor.density) * volume * gravitation
externalV.y -= buoyancy * delta
// Apply fluid drag
val drag = fluid.viscosity / 16.0
externalV.scl(drag)
}
Collision Response
Separation
When actors collide, separate them:
fun separate(actor: ActorWithBody, block: Hitbox) {
val overlapX = min(actor.hitbox.endX - block.startX, block.endX - actor.hitbox.startX)
val overlapY = min(actor.hitbox.endY - block.startY, block.endY - actor.hitbox.startY)
if (overlapX < overlapY) {
// Separate horizontally
if (actor.hitbox.centerX < block.centerX) {
actor.translatePosition(-overlapX, 0.0)
} else {
actor.translatePosition(overlapX, 0.0)
}
actor.externalV.x = 0.0
} else {
// Separate vertically
if (actor.hitbox.centerY < block.centerY) {
actor.translatePosition(0.0, -overlapY)
} else {
actor.translatePosition(0.0, overlapY)
}
actor.externalV.y = 0.0
}
}
Bouncing
Apply elasticity on collision:
val restitution = 0.5 // 0 = no bounce, 1 = perfect bounce
if (collisionWithGround) {
externalV.y = -externalV.y * restitution
if (abs(externalV.y) < minBounceVelocity) {
externalV.y = 0.0 // Stop bouncing
}
}
Performance Optimisations
Spatial Partitioning
The engine uses PR-Tree for efficient actor queries:
val actorTree: PRTree<ActorWithBody>
// Query actors in area
val nearbyActors = actorTree.find(
minX, minY,
maxX, maxY
)
Sleeping Actors
Stationary actors can be flagged as sleeping:
var isStationary: Boolean = true
Sleeping actors skip expensive physics calculations.
PHYS_EPSILON_DIST
Small epsilon for floating-point precision:
companion object {
const val PHYS_EPSILON_DIST = 1.0 / 256.0
}
Used to avoid edge cases in collision detection.
Common Patterns
Jumping
fun jump(jumpStrength: Double) {
if (onGround) {
externalV.y = -jumpStrength
onGround = false
}
}
Walking
fun moveHorizontal(direction: Double, speed: Double) {
controllerV?.x = direction * speed
}
Applying Knockback
fun applyKnockback(angle: Double, force: Double) {
externalV.x += cos(angle) * force
externalV.y += sin(angle) * force
}
Ground Check
fun isOnGround(): Boolean {
val feetY = hitbox.endY
val checkY = (feetY + PHYS_EPSILON_DIST) / TILE_SIZE
for (tx in intTilewiseHitbox.startX.toInt()..intTilewiseHitbox.endX.toInt()) {
val block = world.getTileFromTerrain(tx, checkY.toInt())
if (BlockCodex[block]?.isSolid == true) {
return true
}
}
return false
}
Multi-Threading
The engine supports multi-threaded actor updates:
class ThreadActorUpdate(
val actors: List<ActorWithBody>,
val delta: Float
) : Runnable {
override fun run() {
actors.forEach { it.update(delta) }
}
}
However, multi-threading is currently disabled as it's slower for typical actor counts (< tens of thousands).
Debug Visualisation
Render Hitboxes
if (App.IS_DEVELOPMENT_BUILD) {
shapeRenderer.begin(ShapeRenderer.ShapeType.Line)
shapeRenderer.setColor(Color.RED)
shapeRenderer.rect(
hitbox.startX.toFloat(),
hitbox.startY.toFloat(),
hitbox.width.toFloat(),
hitbox.height.toFloat()
)
shapeRenderer.end()
}
Velocity Vectors
shapeRenderer.line(
position.x, position.y,
position.x + externalV.x * 10, position.y + externalV.y * 10
)
Best Practises
- Use controller velocity for input — Keep player control separate from physics
- Apply forces gradually — Large instantaneous forces cause clipping
- Check TILE_SIZE boundaries — Ensure coordinates are in correct space
- Clamp velocities — Prevent excessive speeds causing tunnelling
- Mark immobile actors — Optimise performance for static objects
- Use epsilon for comparisons — Account for floating-point precision
- Profile physics calculations — Identify expensive collision checks
Limitations
- No rotation — All hitboxes are axis-aligned
- Gravity is downward only — No reverse or lateral gravity
- Single-threaded by default — Multi-threading disabled for performance
- Discrete collision — Fast-moving objects can tunnel through thin obstacles
Future Enhancements
Possible improvements:
- Continuous collision detection
- Rotated hitboxes
- Soft-body physics
- Better fluid simulation
- Multi-directional gravity
See Also
- Glossary — Physics terminology
- Actors — Actor system using physics
- World — World structure and tile properties
- Modules:Blocks — Defining block physics properties