PointerDrag
Grab things with the mouse and slide them in the plane that faces the camera.
What it does
The pointer is a ray from the camera through the cursor. Hover tests it against an invisible sphere round each enabled draggable, and the nearest hit wins. A press grabs the hovered entity. Each frame after that, the entity moves to where the ray crosses the plane through it that faces the camera, keeping its grab offset and staying inside its box. Under a camera that looks straight down an axis, that plane is exactly two world axes: X and Z from the top, X and Y from the side.
The System listens for pointer events on the render canvas, because Babylon swallows mousedown there. The handlers only store the latest pointer state, and the System does all the work in its own update, so it is the one writer of a dragged entity's position. A mesh moved from a DOM handler would land between frames and race every other system.
A click is worked out from the pointer stream, not taken from the browser: a primary press and release that never strayed more than 6 CSS pixels and grabbed nothing. The grab is noted on the press, so a hole that leaves play while held (the player presses R) does not turn the release into a click on the space it left.
Use it in a scene
A scene is just data — a list of entities, each with its components. At startup the SceneLoader turns this JSON into a live world; edit the file and reload to rebuild it.
Build it from scratch with the bjs CLI:
- 1Scaffold a project
World, renderer, and dev server — ready to run.
- 2Install dependencies
- 3Add PointerDrag
Copies its source into src/ so the scene resolves.
First time? Run bjs login once.
- 4Paste the scene into src/scenes/arcade-room.ts and run
SceneLoader builds the world from the JSON; reload to rebuild.
The box keeps a hole a few units inside SpaceGolf's field, which runs 36 each way on X and 16 on Y and Z.
Props
pickRadius(number, default1.5) — radius of the sphere the pointer has to hit to grab the entity.enabled(boolean, defaulttrue) — whether it can be grabbed right now. Hover skips it when false.min(vec3, default[-1e6, -1e6, -1e6]) — lower corner of the box it may be dragged within.max(vec3, default[1e6, 1e6, 1e6]) — upper corner of that box.
Events
Emits:
pointerdrag.hover.changed{ entityId }when the draggable under the pointer changes, withnullfor none.pointerdrag.started/pointerdrag.moved/pointerdrag.ended{ entityId, x, y, z }through a drag, with the entity's position. A pointer held still sends nomoved.pointerdrag.wheel{ entityId, delta }for a turn over the hovered or held entity, in whole notches of 90 pixels. +1 is away from you. A black hole reads it as mass. A turn over empty space is dropped and the page scrolls.pointerdrag.context{ entityId }for a right-click on a draggable, which also blocks the browser menu there.pointerdrag.empty.clicked{ x, y, z }for a click on empty space, at the spot where the ray crosses the plane through the world origin.
Listens:
camera.active.changed{ handle }for the camera to cast rays from.AxisViewCamerasends it.arccamera.created{ handle }as a fallback. An announced active camera wins.
Dependencies
MeshPrimitive. The position lives there: the System reads it to pick and writes it to drag. The scene also needs a camera that announces itself.
Notes
- Make
pickRadiusgenerous. Under a narrow camera a small target is a few pixels across, and a missed grab is the worst thing a drag tool can do. - Empty clicks land on a plane through the world origin, so centre the play field there. In a top view a click lands at height 0, in a side view at depth 0.
- The drag plane is set at the press and kept until release, so a view switched mid-drag still drags along the old plane.
- A pool blueprint (an entity carrying
Pool) is never offered to the pointer. An entity that leaves play mid-drag still getspointerdrag.ended. - Rays start on the camera's near plane and use the pointer's place in the canvas's CSS box, so they land true at any pixel ratio.




































