⏱️ Reading time: 12 min
Mark Blah’s Walker Playground drops, drags, and launches stick figures across a web page without a single game engine involved. Every floor, wall, and platform is a real HTML element: a button, a paragraph, the edge of a card. Resizing the window quickly can launch a figure into the air, because the floor beneath its feet literally moved.
📑 En este artículo
- TL;DR
- What Is DOM-Based 2D Physics?
- Why It Matters
- How It Works
- Practical Examples
- Getting Started
- Real-World Use Cases
- Common Mistakes and Best Practices
- Comparison with Alternatives
- Going Deeper
- Frequently Asked Questions
- Do I Need WebGL or Canvas to Achieve 2D Physics Without an Engine?
- What Makes a DOM-Based Physics Simulation Feel Sluggish?
- Does DOM-Based Physics Work the Same in Every Browser?
- When Should You Switch from a Homemade Physics Engine to a Library Like Matter.js?
- Why Doesn’t a Paragraph’s Text Block a Walker in a Rect-Based Collision Simulation?
- References
That idea, doing 2D physics without an engine by simply measuring the DOM, needs no canvas, no WebGL, and no physics library. getBoundingClientRect(), a loop with requestAnimationFrame(), and some basic algebra are enough.
TL;DR
- getBoundingClientRect() returns an element’s real position and size on screen, with no need for canvas or WebGL.
- A loop with requestAnimationFrame adds gravity to a vertical velocity and moves the element with transform: translate.
- Comparing the edges of two rects (bottom against top) is enough to detect AABB-style collisions between two elements.
- Storing the rect from a previous frame and subtracting it from the current one calculates the velocity that a resize imparts to an object.
- Range.getClientRects() returns one rect per line of text, the foundation for a paragraph to behave like several floors.
What Is DOM-Based 2D Physics?
DOM-based 2D physics is the technique of simulating gravity, velocity, and collisions using only the DOM’s real measurements, the position and size of each element via getBoundingClientRect(), without canvas, WebGL, or an external game engine: the document itself acts as both stage and sensor.
There’s no “fake” physics drawn on a separate canvas. If a button measures 120 by 40 pixels and sits at a certain position on the screen, those are exactly the numbers the physics loop uses to decide whether a figure collides with it or passes through.
Why It Matters
Most physics on the web live behind a black box: a canvas painted pixel by pixel, or an engine like Matter.js that abstracts springs and friction. The walker playground flips that around: the simulation runs on the document itself, and every button, image, or line of text already on the page becomes a real obstacle without being redrawn.
This matters for two reasons. First, pedagogically: building a physics simulation on the DOM requires fully understanding what getBoundingClientRect() returns, when the browser recalculates layout, and why reading and writing styles in the wrong order destroys performance. Second, practically: the same technique works for notifications that fall with gravity, cards that get flicked away to dismiss them, or 404 error pages with elements that react to window resizing.
There’s no need to load a heavy library to pull this off. The complete code for basic physics (gravity, floor, drag) fits in under 60 lines of JavaScript and runs in any modern browser with no dependencies.
How It Works
To achieve DOM-based 2D physics, the main loop needs to solve three problems every frame: measure where everything is, accelerate each object according to gravity, and correct position when two rects overlap.
The Main Loop: requestAnimationFrame
Every browser frame fires a function that updates the physics state and requests the next frame. There’s no fixed timer: requestAnimationFrame() syncs with the screen’s actual refresh rate, so on a 60Hz monitor it runs about 60 times per second.
flowchart TD
A["Measure rects with getBoundingClientRect"] --> B["Apply gravity to velocity"]
B --> C["Move the walker with transform"]
C --> D["Compare walker and floor edges"]
D --> E{"Is there a collision?"}
E -->|"yes"| F["Correct position and set vy to 0"]
E -->|"no"| G["Keep falling"]
F --> A
G --> A
Gravity and Velocity
The simplest integration (and the one almost every DOM-based physics demo uses) is semi-implicit Euler: each frame the vertical velocity grows by a fixed amount (gravity), then the position moves according to that velocity. It’s not as exact as real physics, but it’s visually indistinguishable and trivial to code.
Detecting Collisions by Comparing Rects (AABB)
Two axis-aligned rectangles (no rotation) collide if they overlap on both axes at once. For a figure that only falls, it’s enough to compare the walker’s bottom edge against the floor’s top edge: if rectWalker.bottom >= rectPiso.top, there’s contact.
Special Platforms: Text, Forms, and Borders
The walker playground doesn’t treat every element the same way. A card’s borders are truly solid (you have to walk along the top edge or get trapped inside). Buttons and form fields also block movement. Text, on the other hand, is a floor that can be stood on but also passed through, so a figure can fall line by line inside a long paragraph.
Practical Examples
Let’s walk through the full progression, from a figure that falls endlessly to one that collides with a floor and reacts to resizing.
First, a walker that only falls, with no floor:
const walker = document.getElementById('walker');
let y = 0;
let vy = 0;
const gravedad = 0.6;
function tick() {
vy += gravedad;
y += vy;
walker.style.transform = `translateY(${y}px)`;
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
With gravity = 0.6, velocity grows by 0.6 per frame: 0.6, 1.2, 1.8, 2.4, 3.0… Adding up the position frame by frame, after exactly 5 frames y equals 9 (0.6 + 1.8 + 3.6 + 6.0 + 9.0 by accumulation, verifiable by summing vy at each step). Without a floor, the walker keeps accelerating forever and exits the screen.
Now let’s add a real floor and compare rects to stop the fall:
function detenerEnElPiso() {
const rectWalker = walker.getBoundingClientRect();
const rectPiso = piso.getBoundingClientRect();
if (rectWalker.bottom >= rectPiso.top) {
y -= rectWalker.bottom - rectPiso.top;
vy = 0;
}
}
If the floor is 300px from the top and the walker is 40px tall, the condition is met as soon as y reaches 260: at that point rectWalker.bottom equals exactly 300, the same number as rectPiso.top, and the walker gets pinned to the floor instead of passing through it.
Finally, the trick that makes the walker playground memorable: turning a resize into a launch.
let pisoRectAnterior = piso.getBoundingClientRect();
window.addEventListener('resize', () => {
const pisoRectActual = piso.getBoundingClientRect();
const deltaY = pisoRectActual.top - pisoRectAnterior.top;
vy += deltaY;
pisoRectAnterior = pisoRectActual;
});
If the floor rises 50px between two consecutive resize events (pisoRectActual.top goes from 300 to 250), deltaY equals -50, and that amount is added directly to vy: the walker shoots upward with the same velocity the floor moved.
Getting Started
Dependencies: git, a modern browser, and optionally python3 (preinstalled on macOS and most Linux distros; on Windows it’s downloaded from python.org) to serve the files over HTTP instead of opening them with file://, which blocks some browser APIs.
git clone https://github.com/markblah/lemmings.git
cd lemmings
python3 -m http.server 8000
Then open http://localhost:8000 in the browser. The command is identical on Windows, macOS, and Linux; on Windows, if python3 isn’t recognized, try python -m http.server 8000.
Real-World Use Cases
The technique is more than a curiosity. Notifications that fall with gravity when they appear, cards that can be flicked away to dismiss them, portfolios with easter eggs where the logo falls if you drag it, and 404 error pages where the broken elements literally fall apart are all direct uses of the same rect-based collision simulation.
It also works as an exercise for better understanding drag interactions with inertia, like a kanban board where a card released with velocity keeps sliding for a few milliseconds before stopping, instead of halting abruptly the moment the mouse is released.
Common Mistakes and Best Practices
The first mistake, and the most expensive one: interleaving DOM reads and writes. Calling getBoundingClientRect() right after changing a style forces a synchronous reflow, and doing it for every object on every frame multiplies the cost.
💡 Tip: read all the rects you need in a single pass at the start of the frame, and only then write the new styles. If you interleave reads and writes, every getBoundingClientRect() forces a synchronous reflow and the framerate collapses with more than a dozen elements.
Second mistake: moving objects by changing top or left instead of transform. Changing top triggers layout on every frame; transform: translate() only asks the compositor to repaint, without recalculating the entire document.
Third mistake: not normalizing for real time between frames. Adding a fixed gravity per frame ties the fall speed to the monitor’s framerate: a walker falls noticeably faster on a 120Hz screen than on a 60Hz one if the code doesn’t correct for the real time delta between frames.
Fourth mistake: forgetting about scroll. getBoundingClientRect() returns coordinates relative to the viewport, not the full document, so if the walker’s container has its own scroll, you need to add window.scrollY and window.scrollX before comparing rects.
sequenceDiagram
participant U as User
participant W as Window
participant P as Floor
participant M as Walker
U->>W: drags the edge to resize
W->>P: fires resize event
P->>M: new getBoundingClientRect position
M->>M: calculates vy = current top minus previous top
Note over M: a large delta launches the walker into the air
Comparison with Alternatives
DOM-based physics isn’t the only option, and it helps to know when to switch to something else.
| Option | When to Use It | Advantage | Limitation |
|---|---|---|---|
| DOM + getBoundingClientRect | Prototypes, easter eggs, lightweight physics UI over real HTML | Zero dependencies, interacts with real buttons and text | Performance drops with dozens of elements due to reflow cost |
| Canvas 2D with a custom loop | Simulations with dozens or hundreds of objects | Draws pixel by pixel, doesn’t trigger document reflow | Buttons and text must be reimplemented by hand inside the canvas |
| WebGL or WebGPU | Thousands of particles or demanding visual effects | GPU parallelism, the highest performance ceiling | Steep learning curve, overkill for a simple UI |
| Physics engine (Matter.js, planck.js) | Simulations with realistic springs, friction, and torque | Complex collisions and constraints already solved and tested | Adds an external dependency and reduces fine-grained control over the loop |
Going Deeper
The detail that makes it possible for a paragraph to behave like a staircase of narrow floors, instead of a solid block, is Range.getClientRects(): unlike getBoundingClientRect(), which returns a single rect wrapping all the content, this method returns an array with one DOMRect per visual line of text.
const rango = document.createRange();
rango.selectNodeContents(parrafo);
const lineas = rango.getClientRects();
console.log(lineas.length);
Each element in lineas is independent: one per line of the paragraph, even though all the lines live inside the same <p> element. That’s why a long paragraph can behave like several narrow floors instead of a single block.
📌 Note: Range.getClientRects() is the only standard way to get one rect per line of text; getBoundingClientRect() on the same paragraph returns a single rect wrapping all the lines together.
The walker playground’s “drag to pick up and flick to launch” probably uses the same math as the resize trick: instead of comparing the floor’s rect between two frames, it compares the pointer’s position between two consecutive pointermove events, and on release (pointerup) transfers that delta to the walker’s velocity.
On performance: measuring every “solid” element (borders, buttons, images, fields) on every frame and for every walker is a cost that grows with the product of both quantities. A typical optimization is to read all the solid rects once per frame, store them in a list, and invalidate that list only when a resize, scroll, or layout mutation occurs, instead of re-measuring for each walker individually.
stateDiagram-v2
[*] --> Falling
Falling --> OnFloor: collides with a surface
OnFloor --> Dragged: the mouse picks it up
Dragged --> Launched: released with velocity
Launched --> Falling: gravity acts again
OnFloor --> Falling: the floor moves beneath it
Your next step: clone the walker playground repository, open devtools, and pause with a breakpoint on the floor’s getBoundingClientRect() call while you drag the window’s edge, to watch bottom and top change live.
Frequently Asked Questions
Do I Need WebGL or Canvas to Achieve 2D Physics Without an Engine?
No. getBoundingClientRect() and a loop with requestAnimationFrame() are enough to simulate gravity and collisions directly on HTML elements, though performance suffers with many objects at once.
What Makes a DOM-Based Physics Simulation Feel Sluggish?
Almost always, interleaving style reads and writes: every getBoundingClientRect() after modifying a style forces a synchronous browser reflow.
Does DOM-Based Physics Work the Same in Every Browser?
Yes, because getBoundingClientRect(), requestAnimationFrame(), and Range.getClientRects() are standard APIs that have been supported for years in Chrome, Firefox, and Safari.
When Should You Switch from a Homemade Physics Engine to a Library Like Matter.js?
When the project needs springs, friction, torque, or constraints between multiple bodies: at that point, reimplementing those formulas by hand stops being worthwhile compared to using a proven library.
Why Doesn’t a Paragraph’s Text Block a Walker in a Rect-Based Collision Simulation?
Because the walker playground treats each line of text as a floor that can be passed through and also landed on, unlike borders, buttons, and images, which are truly solid.
References
- Walker Playground: Mark Blah’s original demo that inspired this article, with staircases, floating platforms, cards, and text as floors.
- GitHub Repository: source code for the walker playground to clone and run locally.
- MDN: Element.getBoundingClientRect(): official reference for the API that measures an element’s position and size.
- MDN: Range.getClientRects(): documentation on how to get one rect per visual line of text.
- MDN: window.requestAnimationFrame(): reference for the method used to schedule the animation loop.
📱 Do you like this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day.
Featured image: Foto de Mohammad Rahmani en Unsplash
Did it work for you? Got a different error? Say so below: questions get answered and help the next reader.
Leave a comment
0 Comments