Gravity sim (xkcd 2347)
A recreation of the xkcd "Dependency" comic (#2347) using tldraw shapes and Rapier 2D physics.
import RAPIER from '@dimforge/rapier2d-compat'
import { useEffect, useRef } from 'react'
import { createShapeId, Tldraw, TLShapeId, toRichText, useEditor } from 'tldraw'
import 'tldraw/tldraw.css'
// Block positions from the xkcd "Dependency" comic (#2347)
// Each group of 4 values is [x, y, width, height]
// y=0 is the bottom of the tower, y increases going up
// Source: https://editor.p5js.org/isohedral/sketches/vJa5RiZWs
const RAW_DATA = [
0.5, 0.5, 217.113, 16.4414, 14.168, 16.9414, 181.059, 19.4141, 28.4297, 36.3555, 55.668, 10.6953,
158.977, 36.3555, 7.921, 25.7539, 36.5547, 47.0508, 42.9844, 15.0586, 31.9961, 62.1094, 144.4099,
8.7148, 23.2891, 70.8242, 159.1059, 48.2578, 15.918, 119.082, 83.4336, 106.715, 20.1875, 225.797,
71.4023, 13.973, 102.844, 119.082, 85.371, 32.207, 102.844, 151.289, 89.254, 33.762, 24.9766,
239.77, 14.6132, 19.457, 47.6055, 239.77, 11.7851, 13.562, 64.5781, 239.77, 27.0117, 20.636,
66.4609, 260.406, 21.2149, 41.016, 69.2891, 301.422, 6.3672, 15.558, 79.4258, 301.422, 6.6015,
25.457, 64.5781, 316.98, 12.4922, 4.95, 75.6563, 326.879, 13.4375, 9.43, 75.3047, 336.309, 6.3086,
9.07, 84.3086, 336.309, 5.9609, 8.554, 52.375, 253.332, 5.2578, 15.426, 21.1602, 259.227, 27.3515,
7.675, 20.1875, 266.902, 5.6094, 7.883, 28.5781, 266.902, 5.7188, 14.012, 38.7773, 266.902,
4.6368, 10.2, 22.9922, 280.914, 17.332, 5.149, 27.1875, 286.063, 5.0938, 27.351, 27.1875, 313.414,
4.4688, 10.82, 34.8359, 286.063, 9.3516, 10.355, 35.5313, 296.418, 7.8828, 7.879, 105.641,
185.051, 13.078, 11.426, 104.254, 196.477, 4.359, 6.101, 109.813, 196.477, 4.96, 19.429, 106.434,
215.906, 10.968, 6.231, 109.813, 222.137, 3.601, 6.617, 181.543, 185.051, 12.844, 19.719, 183.684,
204.77, 5.754, 14.253, 179.27, 219.023, 15.117, 8.622, 180.207, 227.645, 4.547, 7.89, 189.438,
227.645, 4.414, 6.421, 123.215, 185.051, 20.336, 52.625, 137.531, 237.676, 9.938, 11.238, 127.766,
237.676, 8.16, 24.215, 152.379, 185.051, 23.414, 68.812, 156.125, 253.863, 14.383, 14.383,
159.605, 268.246, 9.497, 10.367, 121.609, 261.891, 19.133, 16.722, 116.395, 278.613, 59.398,
10.703, 166.961, 289.316, 12.309, 21.004, 170.508, 310.32, 5.285, 12.844, 113.414, 289.316,
11.941, 10.5, 118.668, 299.816, 5.352, 13.715, 115.59, 313.531, 10.433, 6.555, 131.176, 289.316,
30.703, 38.129, 130.305, 327.445, 14.582, 9.899, 150.641, 327.445, 14.984, 9.899, 137.598,
337.344, 22.742, 9.765, 138.867, 347.109, 4.684, 14.18, 147.469, 347.109, 3.976, 10.301, 153.852,
347.109, 4.949, 10.301,
]
const CANVAS_H = 434
const SCALE = 3
// The "critical dependency" block — a small block near the base of the tower
const DEPENDENCY_BLOCK_INDEX = 3
// tldraw colors to cycle through
const COLORS = [
'light-blue',
'light-green',
'yellow',
'orange',
'light-red',
'light-violet',
'blue',
'green',
'red',
'violet',
] as const
interface Block {
x: number
y: number
w: number
h: number
}
function processBlocks(): Block[] {
const blocks: Block[] = []
const scaled = RAW_DATA.map((v) => v * 1.07)
for (let i = 0; i < scaled.length; i += 4) {
const x = (scaled[i] + 20) * SCALE
const y = (CANVAS_H - scaled[i + 1] - scaled[i + 3] - 20) * SCALE
const w = scaled[i + 2] * SCALE
const h = scaled[i + 3] * SCALE
blocks.push({ x, y, w, h })
}
return blocks
}
function XkcdDependency() {
const editor = useEditor()
const worldRef = useRef<RAPIER.World | null>(null)
const bodiesRef = useRef<{ id: TLShapeId; body: RAPIER.RigidBody; w: number; h: number }[]>([])
useEffect(() => {
let cancelled = false
let onTick: (() => void) | null = null
let removeListener: (() => void) | null = null
const blocks = processBlocks()
const depBlock = blocks[DEPENDENCY_BLOCK_INDEX]
const shapes = blocks.map((block, i) => {
const id = createShapeId(`xkcd-${i}`)
return {
id,
type: 'geo' as const,
x: block.x,
y: block.y,
props: {
w: block.w,
h: block.h,
fill: 'solid' as const,
color: i === DEPENDENCY_BLOCK_INDEX ? ('red' as const) : COLORS[i % COLORS.length],
geo: 'rectangle' as const,
size: 's' as const,
},
}
})
const titleId = createShapeId('xkcd-title')
const minX = Math.min(...blocks.map((b) => b.x))
const maxX = Math.max(...blocks.map((b) => b.x + b.w))
const minY = Math.min(...blocks.map((b) => b.y))
const annotationId = createShapeId('xkcd-annotation')
const arrowId = createShapeId('xkcd-arrow')
const depId = createShapeId(`xkcd-${DEPENDENCY_BLOCK_INDEX}`)
const creditId = createShapeId('xkcd-credit')
const groundY = blocks.reduce((max, b) => Math.max(max, b.y + b.h), 0) + 10
editor.createShapes([
...shapes,
{
id: titleId,
type: 'text',
x: minX + (maxX - minX) / 2 - 200,
y: minY - 80,
props: {
richText: toRichText('All modern digital infrastructure'),
size: 'l',
color: 'black',
},
},
{
id: annotationId,
type: 'text',
x: depBlock.x + 220,
y: depBlock.y + depBlock.h - 300,
props: {
richText: toRichText(
'A project some\nrandom person\nin Nebraska has\nbeen thanklessly\nmaintaining\nsince 2003'
),
size: 's',
color: 'black',
},
},
{
id: arrowId,
type: 'arrow',
x: depBlock.x - 30,
y: depBlock.y + depBlock.h + 50,
props: {
start: { x: 0, y: 0 },
end: { x: 30, y: -50 },
color: 'black',
size: 's',
kind: 'arc',
bend: -70,
elbowMidPoint: 0.5,
arrowheadStart: 'none',
arrowheadEnd: 'arrow',
},
},
{
id: creditId,
type: 'text',
x: minX,
y: groundY + 30,
props: {
richText: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'With apologies to ' },
{
type: 'text',
text: 'xkcd.com/2347',
marks: [{ type: 'link', attrs: { href: 'https://xkcd.com/2347' } }],
},
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Inspired by ' },
{
type: 'text',
text: 'editor.p5js.org/isohedral',
marks: [
{
type: 'link',
attrs: {
href: 'https://editor.p5js.org/isohedral/full/vJa5RiZWs',
},
},
],
},
],
},
],
},
size: 's',
color: 'grey',
},
},
])
editor.createBindings([
{
fromId: arrowId,
toId: depId,
type: 'arrow',
props: {
terminal: 'end',
normalizedAnchor: { x: 0.5, y: 0.5 },
isPrecise: false,
isExact: false,
},
},
{
fromId: arrowId,
toId: annotationId,
type: 'arrow',
props: {
terminal: 'start',
normalizedAnchor: { x: 0.5, y: 0.5 },
isPrecise: true,
isExact: false,
},
},
])
editor.zoomToFit({ animation: { duration: 300 } })
RAPIER.init().then(() => {
if (cancelled) return
const world = new RAPIER.World({ x: 0, y: 200 })
worldRef.current = world
// Static ground body below the tower
const groundBody = world.createRigidBody(
RAPIER.RigidBodyDesc.fixed().setTranslation((minX + maxX) / 2, groundY + 50)
)
world.createCollider(RAPIER.ColliderDesc.cuboid(((maxX - minX) * 3) / 2, 50), groundBody)
// Dynamic bodies for each block
const blockIdSet = new Set<string>()
const bodies: typeof bodiesRef.current = []
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
const id = createShapeId(`xkcd-${i}`)
blockIdSet.add(id)
const body = world.createRigidBody(
RAPIER.RigidBodyDesc.dynamic()
.setTranslation(block.x + block.w / 2, block.y + block.h / 2)
.setCanSleep(true)
)
world.createCollider(
RAPIER.ColliderDesc.cuboid(block.w / 2, block.h / 2)
.setRestitution(0.05)
.setFriction(0.1),
body
)
bodies.push({ id, body, w: block.w, h: block.h })
}
bodiesRef.current = bodies
// Warm up: settle any overlaps from the initial layout, then sleep everything
for (let i = 0; i < 120; i++) world.step()
const settled: { id: TLShapeId; type: 'geo'; x: number; y: number; rotation: number }[] = []
for (const { id, body, w, h } of bodies) {
const pos = body.translation()
settled.push({
id,
type: 'geo',
x: pos.x - w / 2,
y: pos.y - h / 2,
rotation: body.rotation(),
})
body.sleep()
}
editor.updateShapes(settled)
let updatingFromPhysics = false
const kinematicIds = new Set<string>()
// Only handle deletions — drag syncing is done in the tick handler
removeListener = editor.store.listen(
({ changes }) => {
if (updatingFromPhysics) return
for (const key of Object.keys(changes.removed)) {
if (!blockIdSet.has(key)) continue
blockIdSet.delete(key)
kinematicIds.delete(key)
const idx = bodiesRef.current.findIndex((b) => b.id === key)
if (idx !== -1) {
world.removeRigidBody(bodiesRef.current[idx].body)
bodiesRef.current.splice(idx, 1)
}
for (const { body } of bodiesRef.current) body.wakeUp()
}
},
{ source: 'user', scope: 'document' }
)
onTick = () => {
if (!worldRef.current) return
const selectedIds = new Set(editor.getSelectedShapeIds() as string[])
// Release bodies that are no longer selected back to dynamic
for (const id of kinematicIds) {
if (selectedIds.has(id)) continue
kinematicIds.delete(id)
const entry = bodiesRef.current.find((b) => b.id === id)
if (!entry) continue
entry.body.setBodyType(RAPIER.RigidBodyType.Dynamic, true)
entry.body.setLinvel({ x: 0, y: 0 }, true)
entry.body.setAngvel(0, true)
for (const { body } of bodiesRef.current) body.wakeUp()
}
// Switch selected bodies to kinematic so they follow the user's drag
for (const entry of bodiesRef.current) {
if (!selectedIds.has(entry.id as string)) continue
if (!kinematicIds.has(entry.id as string)) {
entry.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true)
kinematicIds.add(entry.id as string)
}
const shape = editor.getShape(entry.id)
if (!shape) continue
entry.body.setNextKinematicTranslation({
x: shape.x + entry.w / 2,
y: shape.y + entry.h / 2,
})
entry.body.setNextKinematicRotation(shape.rotation)
}
world.step()
// Sync non-selected, non-sleeping bodies back to tldraw shapes
const updates: {
id: TLShapeId
type: 'geo'
x: number
y: number
rotation: number
}[] = []
for (const { id, body, w, h } of bodiesRef.current) {
if (kinematicIds.has(id as string)) continue
if (body.isSleeping()) continue
const pos = body.translation()
const bx = pos.x - w / 2
const by = pos.y - h / 2
if (!isFinite(bx) || !isFinite(by)) continue
updates.push({ id, type: 'geo', x: bx, y: by, rotation: body.rotation() })
}
if (updates.length > 0) {
updatingFromPhysics = true
editor.updateShapes(updates)
updatingFromPhysics = false
}
}
editor.on('tick', onTick)
})
return () => {
cancelled = true
if (onTick) editor.off('tick', onTick)
if (removeListener) removeListener()
if (worldRef.current) {
worldRef.current.free()
worldRef.current = null
}
const shapeIds: TLShapeId[] = blocks.map((_, i) => createShapeId(`xkcd-${i}`))
shapeIds.push(titleId, annotationId, arrowId, creditId)
editor.deleteShapes(shapeIds)
}
}, [editor])
return null
}
export default function XkcdDependencyExample() {
return (
<div className="tldraw__editor">
<Tldraw>
<XkcdDependency />
</Tldraw>
</div>
)
}
The tower of blocks is held up by gravity from the start. Try pulling out a block and watch everything collapse — just like in the original comic.
Is this page helpful?
Prev
Many shapesNext
Mermaid diagrams