Snapping a Draggable Node to a Grid
Task: a draggable object (a puzzle piece, a graph point) should visually jump to the nearest cell of a fixed grid while being dragged, rather than following the pointer at arbitrary sub-pixel positions.
DragListener and KeyboardDragListener both accept a mapPosition option — a ( point: Vector2 ) => Vector2 hook that runs on every proposed position before it's written to positionProperty (and before dragBoundsProperty clamping). Returning a rounded point from mapPosition is the standard way to snap a drag to a grid without hand-rolling position math inside drag/end callbacks.
The solution
import { DragListener, Rectangle } from 'scenerystack/scenery';
import { Property } from 'scenerystack/axon';
import { Vector2, roundToInterval } from 'scenerystack/dot';
import { Tandem } from 'scenerystack/tandem';
const GRID_CELL_SIZE = 20; // model units per grid cell
// --- model ---
const positionProperty = new Property( new Vector2( 0, 0 ) );
// --- view ---
const pieceNode = new Rectangle( -8, -8, 16, 16, {
fill: 'mediumseagreen',
cursor: 'pointer'
} );
positionProperty.link( position => {
pieceNode.translation = position;
} );
pieceNode.addInputListener( new DragListener( {
positionProperty: positionProperty,
// Snap every candidate position to the nearest grid intersection before
// it's written to positionProperty.
mapPosition: ( point: Vector2 ) => new Vector2(
roundToInterval( point.x, GRID_CELL_SIZE ),
roundToInterval( point.y, GRID_CELL_SIZE )
),
tandem: Tandem.REQUIRED
} ) );roundToInterval (from scenerystack/dot, alongside the other numeric helpers documented in Utils) rounds a value to the nearest multiple of a given interval — exactly the "nearest grid line" computation, applied independently to x and y.
The Node now jumps between grid cells as it's dragged, rather than following the pointer continuously — try setting GRID_CELL_SIZE to 1 to confirm the un-snapped behavior is unchanged, and larger values (e.g. 50) to make the snapping obvious.
Combining with drag bounds
mapPosition and dragBoundsProperty compose — mapPosition runs first, then the result is clamped into dragBoundsProperty (see Making Any Node Draggable and Bounded to an Area) — so a snapped grid position that would fall just outside the play area is still pulled back in:
import { Bounds2 } from 'scenerystack/dot';
pieceNode.addInputListener( new DragListener( {
positionProperty: positionProperty,
dragBoundsProperty: new Property( new Bounds2( -100, -100, 100, 100 ) ),
mapPosition: ( point: Vector2 ) => new Vector2(
roundToInterval( point.x, GRID_CELL_SIZE ),
roundToInterval( point.y, GRID_CELL_SIZE )
),
tandem: Tandem.REQUIRED
} ) );Options used here
| Option | Effect |
|---|---|
mapPosition | ( point: Vector2 ) => Vector2 — transforms a candidate drag position before it's written to positionProperty; runs before dragBoundsProperty clamping |
positionProperty | The model Property receiving the (now snapped) position |
mapPosition works identically on KeyboardDragListener and RichDragListener
Because mapPosition is part of the shared AllDragListenerOptions every drag listener accepts, adding the same mapPosition function to a KeyboardDragListener (or the keyboardDragListenerOptions of a RichDragListener) snaps keyboard-driven dragging to the same grid, with no separate logic needed — see Drag Listeners for wiring up the keyboard-accessible variants.
Round the point, not the delta
mapPosition receives and must return an absolute point (in the same coordinate frame as positionProperty), not a per-step delta — rounding a delta instead would snap relative to wherever the drag started rather than to a fixed grid, so cells would shift depending on the drag's starting position.