Skip to main content
Version: Next

Physics

Most games should use physics through Scene and GameObject.

In practice, the workflow is:

  1. Set gravity on the scene.
  2. Call setPhisics(...) on the objects that should participate.
  3. Add one or more hitboxes.
  4. Optionally author speed / angularVelocity.
  5. React with beforeColision(...) and onColision(...).

Typical setup:

import { GameObject, Scene, SquareHitbox, Vector } from "sliver-engine";

const scene = new Scene("level");
scene.setGravity(new Vector(0, 1200));

const crate = new GameObject("crate", new Vector(100, 100));
crate.setPhisics({
immovable: false,
affectedByGravity: true,
mass: 2,
staticFriction: 0.5,
dynamicFriction: 0.3,
});
crate.addHitbox(
new SquareHitbox(Vector.zero(), new Vector(32, 32), crate, {
solid: true,
}),
);

scene.addGameObject(crate);

Configuring a body

Use setPhisics(...) to configure how a GameObject behaves in the physics world:

gameObject.setPhisics({
immovable: false,
affectedByGravity: true,
restitution: 0.2,
friction: 0.4,
staticFriction: 0.5,
dynamicFriction: 0.3,
mass: 2,
inertiaScale: 0.85,
});

Physics settings:

  • immovable: true is what you usually want for floors, walls, ramps, and fixed props.
  • immovable: false makes the object dynamic so it can be pushed, fall, and rotate.
  • affectedByGravity is the main switch for platformer-style falling vs top-down or scripted motion.
  • restitution controls bounce.
  • friction is the simple one-value option and acts as a fallback for both friction coefficients.
  • staticFriction is useful when you want objects to hold more firmly before they start slipping.
  • dynamicFriction is useful when you want to control how slippery a contact feels once it is already sliding.
  • mass changes how strongly dynamic bodies push each other around.
  • inertiaScale changes how easily the object spins without changing its mass. Lower values tumble more easily; higher values resist rotation more.

Units and timing

speed and angularVelocity are integrated with dt.

  • speed is a linear velocity in world units (pixels) per second.
  • angularVelocity is a rotational velocity per second in the same angle unit as rotation.
  • dt is a fixed 1 / tickRate in seconds. The physics step does not increase dt to compensate for lag; if the game falls behind, ticks are skipped instead. That means heavy lag can still lead to issues like thin-floor tunneling or noticeable clipping.

Example:

import { Vector } from "sliver-engine";

gameObject.speed = new Vector(120, 0); // 120 px/s

At 60 ticks per second, that moves about 2 pixels each tick because the physics world integrates speed * dt.

Scene gravity is also time-based acceleration:

scene.setGravity(new Vector(0, 1200));

That means a dynamic body with affectedByGravity: true gains roughly 20 units/s of vertical velocity each step at 60 ticks per second.

Hitboxes, compound bodies, and rotation

Physics only exists where hitboxes exist, so a physical object should usually have at least one SquareHitbox or CircleHitbox:

import { GameObject, SquareHitbox, Vector } from "sliver-engine";

const crate = new GameObject("crate", new Vector(100, 100));
crate.setPhisics({ immovable: false, mass: 2 });
crate.addHitbox(
new SquareHitbox(Vector.zero(), new Vector(32, 32), crate, {
solid: true,
debug: false,
}),
);

How hitboxes are used:

  • One GameObject becomes one physics body.
  • Every hitbox on that object becomes a shape on that same body.
  • Multiple hitboxes act like one compound body.
  • Physics rotation uses the same pivot as rendering, so offset hitboxes still rotate with the visible object.

Hitbox options:

  • solid: true makes the hitbox participate in physical collision response.
  • solid: false turns it into a trigger/sensor.
  • debug: true draws the hitbox overlay.

How scenes apply physics

You should not step physics manually. Scene.tick() handles it for you:

  • your objects run tick() first
  • the scene applies gravity and resolves overlaps/collisions
  • the solved position, rotation, speed, and angularVelocity are written back to each object

That means you can author intent in tick() and then let the scene physics step produce the resulting motion.

Sensors, Callbacks, and Suppression

The main gameplay hooks are:

  • beforeColision(other) is the filter hook.
  • onColision(other, penetration) is the overlap notification hook.

Use them like this:

  • return false from beforeColision to ignore a pair for that tick
  • use onColision to react to overlaps, pickups, triggers, damage zones, and simple collision responses
  • mark a hitbox solid: false when you want notifications without physical pushing

Example trigger:

trigger.addHitbox(
new SquareHitbox(Vector.zero(), new Vector(64, 64), trigger, {
solid: false,
}),
);

Example filter:

override beforeColision(other): boolean {
return other.name !== "player-bullet";
}

The penetration passed to onColision comes from the contact manifold normal and penetration depth for that overlap.

Inspecting Physics Results

Scenes expose the last physics step result:

const result = scene.getLastPhysicsStepResult();

console.log(result.candidatePairs);
console.log(result.sensorManifolds);
console.log(result.solidManifolds);
console.log(result.notifiedPairIds);
console.log(result.suppressedPairIds);

This is useful for:

  • debugging sensors vs solid contacts
  • checking whether a pair was suppressed by beforeColision
  • inspecting which pairs were actually solved
  • writing deterministic physics tests

Lower-level physics APIs

Most games should stay with Scene + GameObject + hitboxes. Reach for the lower-level exports only when the high-level API is no longer enough. Common reasons are:

  • explicit PhysicsBody creation
  • kinematic body types
  • collision category/mask filtering
  • direct access to ScenePhysicsWorld
  • custom gameplay collision stepping or solver configuration

Those APIs are exported directly from "sliver-engine", for example:

import {
PhysicsBody,
ScenePhysicsWorld,
stepGameplayCollisions,
} from "sliver-engine";

Live Examples

Physics Material Tuning

This sandbox compares two groups of draggable boxes with different mass, static friction, dynamic friction, restitution, and inertia scaling settings.

export type PhysicsOptions = {
	immovable?: boolean;
	affectedByGravity?: boolean;
	restitution?: number;
	friction?: number;
	staticFriction?: number;
	dynamicFriction?: number;
	mass?: number;
	inertiaScale?: number;
};

type ColorPhysicsOptions = {
	cyan: PhysicsOptions;
	rose: PhysicsOptions;
};

export const createColorPhysicsOptions = (): ColorPhysicsOptions => {
	// Edit this section only.
	const cyanPhysicsOptions = {
		immovable: false,
		affectedByGravity: true,
		restitution: 0.35,
		staticFriction: 0.82,
		dynamicFriction: 0.46,
		mass: 1.2,
		inertiaScale: 0.82,
	};

	const rosePhysicsOptions = {
		immovable: false,
		affectedByGravity: true,
		restitution: 0.8,
		staticFriction: 0.18,
		dynamicFriction: 0.08,
		mass: 0.9,
		inertiaScale: 1.05,
	};

	return {
		cyan: cyanPhysicsOptions,
		rose: rosePhysicsOptions,
	};
};

Scene Gravity

This sandbox shows a single draggable dynamic box under scene gravity. Edit the gravity vector to change the feel of the simulation.

import { Vector } from "sliver-engine";
import { scaleEnclosureScalar } from "./enclosureDimensions";

export const createGravity = () => {
	// Edit this section only.
	const gravityX = 0;
	const gravityY = 1200;

	return new Vector(
		scaleEnclosureScalar(gravityX),
		scaleEnclosureScalar(gravityY),
	);
};

Compound Hitboxes

This sandbox shows objects whose visible shape and collision shape do not have to match one simple rectangle. Edit the hitbox configs to see how one object can behave as a compound body.

import type { Vector } from "sliver-engine";
import {
	mapEnclosurePoint,
	scaleEnclosureOffset,
	scaleEnclosureScalar,
	scaleEnclosureSize,
} from "./enclosureDimensions";

export type HitboxType = "square" | "circle";

export type HitboxConfig = {
	type: HitboxType;
	offset: Vector;
	squareSize: Vector;
	circleRadius: number;
	solid: boolean;
	debug: boolean;
};

export type HitboxDemoObjectConfig = {
	position: Vector;
	shapeSize: Vector;
	shapeColor: string;
	hitboxes: HitboxConfig[];
};

type HitboxDemoConfigs = {
	objectA: HitboxDemoObjectConfig;
	objectB: HitboxDemoObjectConfig;
};

export const createHitboxConfigs = (): HitboxDemoConfigs => {
	// Edit this section only.
	return {
		objectA: {
			position: mapEnclosurePoint(136, 138),
			shapeSize: scaleEnclosureSize(54, 30),
			shapeColor: "#22d3ee",
			hitboxes: [
				{
					type: "square",
					offset: scaleEnclosureOffset(18, -10),
					squareSize: scaleEnclosureSize(22, 22),
					circleRadius: scaleEnclosureScalar(11),
					solid: true,
					debug: true,
				},
				{
					type: "circle",
					offset: scaleEnclosureOffset(-8, 8),
					squareSize: scaleEnclosureSize(18, 18),
					circleRadius: scaleEnclosureScalar(10),
					solid: true,
					debug: true,
				},
			],
		},
		objectB: {
			position: mapEnclosurePoint(308, 144),
			shapeSize: scaleEnclosureSize(48, 44),
			shapeColor: "#fb7185",
			hitboxes: [
				{
					type: "circle",
					offset: scaleEnclosureOffset(-12, 12),
					squareSize: scaleEnclosureSize(26, 18),
					circleRadius: scaleEnclosureScalar(14),
					solid: true,
					debug: true,
				},
				{
					type: "square",
					offset: scaleEnclosureOffset(14, -6),
					squareSize: scaleEnclosureSize(16, 24),
					circleRadius: scaleEnclosureScalar(8),
					solid: true,
					debug: true,
				},
			],
		},
	};
};

Non-solid Trigger Hitboxes

This sandbox shows sensor behavior: the green zone overlaps the moving box and triggers gameplay logic without physically blocking it.

import { GameObject, SquareHitbox, Vector } from "sliver-engine";
import {
	mapEnclosurePoint,
	scaleEnclosureSize,
} from "./enclosureDimensions";

export const TELEPORT_TRIGGER_NAME = "teleport-trigger";

const TRIGGER_SIZE = scaleEnclosureSize(64, 128);
const TRIGGER_POSITION = mapEnclosurePoint(368, 68);

class TriggerZone extends GameObject {
	constructor() {
		super(TELEPORT_TRIGGER_NAME, TRIGGER_POSITION.clone());
		this.addHitbox(
			new SquareHitbox(Vector.zero(), TRIGGER_SIZE.clone(), this, {
				solid: false,
				debug: true,
			}),
		);
		this.setPhisics({
			immovable: true,
		});
		this.setRenderFunction((obj, canvas) => {
			const pos = obj.getPosition();
			canvas
				.getShapeDrawer()
				.drawRectangle(
					pos.x,
					pos.y,
					TRIGGER_SIZE.x,
					TRIGGER_SIZE.y,
					"rgba(16, 185, 129, 0.28)",
					true,
				);
		});
	}
}

export const createTriggerZone = (): TriggerZone => {
	return new TriggerZone();
};