2D Physics#
Rigid bodies that drive VisualElements. The whole world crosses into C# once, and after that the simulation and the writing of positions onto elements both happen there, so a hundred bodies cost JavaScript nothing per frame.
import { createPhysicsWorld } from "onejs-unity/physics2d"
const world = createPhysicsWorld(hostRef.current, {
gravity: [0, 980],
bounds: true,
bodies: [
{ element: ballRef.current, shape: "circle", radius: 16, x: 100, y: 0, restitution: 0.7 },
{ element: floorRef.current, type: "static", size: [600, 20], x: 0, y: 700 },
],
})Everything is in panel units with y down, the way UI Toolkit measures. Gravity of [0, 980] pulls toward the bottom of the screen, which is what you want and the opposite of what a Unity habit expects.
The constraint to design around#
A world is created with all of its bodies at once, and it cannot grow. Bodies are built in C# when the world is, and there is no way to add one afterwards without throwing the simulation away and starting again.
So a game that spawns things does not spawn bodies. It creates every body it will ever need up front, switches them off, and switches one on when something appears:
// Up front: every body that will ever exist, parked out of sight
const BODIES = [
...scenery,
...Array.from({ length: 60 }, () => ({
type: "dynamic", shape: "circle", radius: 12,
// A disabled body still has a position. One left in the middle of the
// field flashes into view for a frame between being switched on and
// being moved.
x: -400, y: -400,
})),
]
// Later: take a free one rather than making a new one
world.setBodyEnabled(index, true)
world.setPosition(index, x, y)
world.setVelocity(index, driftX, 0)Enable the body before you move it. That order is not stylistic. Before OneJS v3.2.1, setPosition on a body whose simulation was switched off did nothing at all: the write was accepted, silently discarded, and the body woke up wherever it had been parked. From v3.2.1 either order works, but a game running on an older runtime is still on the old behaviour, so enable-then-move is the order to write.
It is a costly one to get wrong because the position is a lie rather than an error. Everything downstream is correct about it: rules fire, tests pass, scores update. One game parked its bodies off the field and placed them at the start of each round in the wrong order, so every player spawned outside the arena, correctly reported itself out on the first tick, and the round ended before anyone saw anything. It presented as three unrelated bugs, none of which looked like a position.
That turns "add a shape" into "which one is free", which is a pool. The interesting case is what happens when nothing is free: handing out the oldest live body is what makes a sandbox feel bottomless rather than quietly refusing to add anything more. drop-everything in the OneJS Play examples exists mostly to demonstrate this, has always used the safe order, and its pool.ts is about forty lines.
Pick the pool size from what the game can have on screen at once, not from what it might total over a session.
Bodies#
static bodies never move and are what scenery should be. kinematic bodies move but are not pushed by anything. dynamic is the default and the one gravity acts on.
sensor passes things through but still reports them, which is what pickups, goals and trigger zones are.
tag is a number handed back with every contact, so a handler can tell what hit what without keeping its own index map.
Bodies are referred to by index into the bodies array, everywhere. A useful habit is to put scenery first, so a scenery index is also a body index, and to write a small helper for the rest (const shapeBody = (slot) => SCENERY.length + slot).
Binding elements#
An element is a C# object and cannot travel inside the world's configuration, so it is attached separately. Passing element in a body config does that for you at creation, but refs are empty during render, so in React the binding usually happens after mount:
useEffect(() => {
if (world === null) return
for (let i = 0; i < elements.length; i++) {
if (elements[i]) world.bind(i, elements[i])
}
}, [world])Bind your static scenery too. A rotated ramp then draws at the angle the simulation actually gave it rather than the one you wrote down, and the two cannot drift.
A body with no element still simulates. It is just invisible.
Bounds#
bounds: true puts walls around the host element's rect, so nothing leaves the play area. boundsRestitution and boundsFriction tune them.
The walls follow the host element, which means the host needs a real size. A host that has not been laid out yet has no rect, and the world built against it has walls in the wrong place.
Collisions#
Contacts are off by default, because most bodies are scenery and reporting everything would be noise. Turn them on per body with reportCollisions: true, then register a handler:
world.onCollision((contact) => {
if (contact.tagA === BALL && contact.tagB === GOAL) score++
})| On a contact | |
|---|---|
a, b | Body indices, or -1 for a boundary wall |
tagA, tagB | The tag of each body |
x, y | Where they touched |
Contacts are delivered in one flat batch per frame rather than one call each. That batch is delivered by pump(), which you call once a frame:
useAnimationFrame(() => world.pump())With no handler registered, pump() costs nothing and crosses nothing. If you use usePhysics from oj, it pumps for you.
Driving a running world#
impulse(index, x, y) | A one-off shove |
setVelocity(index, x, y) | Set velocity outright |
setPosition(index, x, y) | Teleport |
setGravity(x, y) | For the whole world |
setBodyEnabled(index, on) | The pool switch |
bind(index, element) | Attach an element later |
readTransforms() | Every body's x, y and rotation, as one flat array |
dispose() | Tear the world down |
readTransforms() is for saving a state or taking a snapshot, not for a frame loop. Reading positions back every frame is exactly the per-body crossing this API is shaped to avoid: the C# side is already writing them onto your elements.
In React#
import { useEffect, useRef } from "react"
import { createPhysicsWorld } from "onejs-unity/physics2d"
import { useAnimationFrame } from "onejs-unity/gpu"
function Sandbox() {
const host = useRef(null)
const world = useRef(null)
useEffect(() => {
world.current = createPhysicsWorld(host.current, { gravity: [0, 980], bounds: true, bodies: BODIES })
return () => world.current.dispose()
}, [])
useAnimationFrame(() => world.current?.pump())
return <View ref={host} style={{ flexGrow: 1 }} />
}Create the world once and never rebuild it from a render. The simulation lives in C# and a re-render that recreated it would restart everything mid-flight. Change a running world through its methods instead.
Always dispose(). The world holds C# objects that outlive the component otherwise.
The oj package wraps all of this in usePhysics(hostRef, config, onCollision?), which creates the world on mount, pumps it each frame, and disposes it on unmount. It reads its config once for the same reason. oj works in any OneJS project, not only on OneJS Play.
Why the API is shaped this way#
It is deliberately not a mirror of Rigidbody2D. An API with body.position and body.velocity would mean two crossings per body per frame from JavaScript, which is exactly the per-entity cost that makes interpreted game code slow. Instead the world crosses once as a document, positions are written onto elements in C#, and contacts come back as one array per frame.
The practical consequence is that reading is expensive and writing is cheap. Push bodies around freely; do not poll them.
Builds#
Physics needs UnityEngine.Physics2DModule, and code stripping on IL2CPP and AOT targets can remove it, since nothing in your C# references it. The failure is loud rather than silent:
[oj] physics is unavailable: OneJS.Physics2DBridge was not found.Preserve the module in a link.xml. See Code Stripping.
Tuning#
pixelsPerUnit defaults to 100 and converts panel units to physics units, which exists because the solver is tuned for bodies a few units across rather than hundreds. Leave it alone unless things feel wrong: bodies that jitter, sink into each other, or behave like they are made of lead are the symptoms of a scale that is far off.
velocityIterations (8) and positionIterations (3) trade accuracy for time in the usual way. Raise them if stacks are mushy, before reaching for anything else.