How to build 2D game-style physics with Matter.js and React Native Skia
Development•React Native••9 minutes read
Daniel Friyia
Guest Author
Learn how to build real-time 2D physics with Matter.js, Skia, and Reanimated in a React Native + Expo app. Perfect for games, simulations, or fun UIs.

This guest post is from Daniel Friyia - a Senior Software Engineer and Expo / React Native YouTuber. He is passionate about educating fellow developers on advanced topics in React Native.
...
If you've ever wanted to build fun 2D physics-based games or interactions in a React Native app, you're in luck. In this post, you’ll learn how to combine Matter.js, a physics engine, with React Native Skia, a drawing engine, to simulate real-world motion and collisions—all in TypeScript, and fully compatible with Expo.
By the end, you'll have a basic game-like simulation: boxes that drop, bounce, and react to a projectile ball that explodes through them. If you want to follow along, you can download the sample code from GitHub. The “main” branch contains the solution and the “starter” branch has all the boilerplate you need to get started.
Core Idea: Syncing Skia and Matter.js
In this project, we’re combining the power of three libraries to bring interactive, physics-driven visuals to life in Expo:
- Matter.js – A robust 2D physics engine written in TypeScript
- React Native Skia – A high-performance graphics library for rendering 2D visuals
- React Native Reanimated – A performant animation engine that runs animations directly on the UI thread
Each library shines in its own domain: Matter.js handles the physics simulation, Skia renders beautiful 2D graphics, and Reanimated keeps everything running smoothly on the UI thread. The challenge? Skia and Matter don’t speak the same language out of the box.
To bridge that gap, we use Reanimated’s shared values to sync data from Matter.js to Skia in real time. This setup lets us reflect the physics simulation directly in our rendered graphics.
I like to think of it this way: Matter.js gives us “invisible” shapes—objects with position, velocity, and collision—but no visuals. Skia gives us the “visible” side—what the user actually sees. Reanimated acts as the glue, making sure what you see matches what’s happening under the hood.
Creating the “World”
At this point we’ll need to create a “world” that all our physical actions can take place in. In the context of Matter.js, a "world" is a container that holds all the bodies, constraints, and composites in a physics simulation. It's essentially the root object that defines the environment in which physics interactions occur. Without it, there’s no context for bodies to simulate real-world physics like collisions or gravity. We can create one with the following code by placing it just outside our component.
const engine = Matter.Engine.create();const world = engine.world;Matter.World.add(world, []);
Creating and displaying boxes
Next, let’s create some boxes and add them into the world as well as the Skia canvas. The first thing we’ll need is an interface all of our Skia Components can use to display the data from matter.
type BoxCoords = {x: SharedValue<number>;y: SharedValue<number>;angle: SharedValue<[{ rotateZ: number }]>;origin: SharedValue<SkPoint>;width: number;height: number;type: "box";}
The x, y, width and height values will be taken directly from Matter or the constants in the boilerplate. When it comes to the angle and the origin we have to make some adjustments to them, but they aren’t that different then what Matter gives us. More on that later though. For now lets create a type that is going to handle these interfaces.
type GameObjects = BoxCoords;
Inside the component we’re going to need to create two important variables. One of them is going to be a state variable that tracks the coordinates of the Skia components. The other is going to be a ref that keeps track of the Matter shapes. Since Matter coordinates never render anywhere, they don’t need to be tracked by useState.
const [elements, setElements] = useState<GameObjects[]>([]);const boxesWorld = useRef<Matter.Body[]>([]);
Now that we can track state, let's move onto the onAddBox function. The function should start by creating a box using the BoxCoords interface.
const newBoxCoords: BoxCoords = {x: makeMutable(width / 2 + 40),y: makeMutable(200),angle: makeMutable([{ rotateZ: 0 }]),width: BOX_SIZE,height: BOX_SIZE,origin: makeMutable(vec(width / 2 + BOX_SIZE / 2, 200 / 2 + BOX_SIZE / 2)),type: "box",};
The values here are just some defaults I chose because they looked good. Feel free to tweak them and experiment as much as you like.
You’ll notice I’m using the makeMutable method—something not many developers are familiar with. Essentially, it lets you declare a Reanimated shared value imperatively rather than declaratively. In most cases, you should use useSharedValue, but because the number of boxes in our game is dynamic, this approach is the most practical.
Next, you'll want to add a small offset so that the rectangles can start in slightly different positions. To do this, create a ref and initialize it to 0 to hold the offset value.
offsetVal.current = (offsetVal.current + 1) % 2;
After that, we’ll copy the same values from the Skia component position and pass them into Matter.
const newBox = Matter.Bodies.rectangle(newBoxCoords.x.value + offsetVal.current * BOX_SIZE,newBoxCoords.y.value,BOX_SIZE,BOX_SIZE,{ restitution: 0.9 });
Finally, we can add these boxes to the world and our state variables.
Matter.World.add(world, newBox);setElements([...elements, newBoxCoords]);boxesWorld.current.push(newBox);
Now, inside the canvas, add this code. When we press onAddBox you should get a box appearing. It won’t move anywhere, but it’ll be on the screen.
{elements.map((box, i) => {return (<Rectx={box.x}y={box.y}height={box.height}width={box.width}color="limegreen"key={i}style="stroke"strokeWidth={3}transform={box.angle}origin={box.origin}/>);})}
Moving the boxes
Moving the boxes is fairly simple now that we have the box set up. Add a useEffect to your component with the following code.
useEffect(() => {let animationFrame: any;function update() {Matter.Engine.update(engine, 1000 / 60);animationFrame = requestAnimationFrame(update);elements.forEach((element, i) => {if (element.type === 'box') {element.x.value = boxesWorld.current[i].position.x;element.y.value = boxesWorld.current[i].position.y;element.angle.value = [{ rotateZ: boxesWorld.current[i].angle }];element.origin.value = vec(element.x.value + BOX_SIZE / 2,element.y.value + BOX_SIZE / 2,);}});};update();return () => cancelAnimationFrame(animationFrame);}, [elements]);
The animation loop inside the useEffect hook serves as the core mechanism that keeps the visual elements in Skia sync with the physics simulation powered by Matter.js. Each frame, the loop calls Matter.Engine.update to advance the physics engine at a consistent 60 FPS, then iterates over all box elements to update their Skia SharedValue properties—like position, angle, and origin—based on the current state of their corresponding Matter.js bodies stored in boxesWorld.current. I like to think of the Algorithm looking something like this.
This ensures that any changes caused by gravity, collisions, or user interaction (like launching the ball or adding boxes) are reflected in the UI in real time. For boxes, it updates the x, y, rotation, and origin to maintain accurate rendering and transformations; for the ball, it adjusts its center coordinates by accounting for its radius. The loop runs continuously via requestAnimationFrame, creating a smooth animation cycle, and it cleans up when the component unmounts or the element list changes to prevent unnecessary memory usage or overlapping loops.
If you want to test things out, change the value added to offsetVal.current to be 1.5. You should see some boxes bouncing off each other.
Building the boundaries of the game
If you’ve run the code up to this point, you’ll notice that the boxes don’t stay in the game—they actually fall through the floor. To keep them within the boundaries of our game, we need to draw some walls. Drawing walls in Matter.js can be a bit tricky, so let’s go over how it works.
The goal is to create invisible walls that align with the edges of the screen. Typically, we use the x and y coordinates for the top-left corner of an object. However, in Matter.js, we need to use the center point because it relies on a physics-based coordinate system that positions objects based on their center rather than their top-left corner.
Take the top wall, for example—its code would look something like this:
const topWall = Matter.Bodies.rectangle(width / 2, 0, width, 1, {isStatic: true,});
I usually envision my mental model of the wall as looking like the diagram below.
The same applies for the walls on the left, right and bottom. Let’s then copy this code for the walls and add them to the world.
// Create the other wallsconst topWall = Matter.Bodies.rectangle(width / 2, 0, width, 1, {isStatic: true,});const bottomWall = Matter.Bodies.rectangle(width / 2,height - BOTTOM_HEIGHT,width,BOTTOM_HEIGHT,{ isStatic: true });const leftWall = Matter.Bodies.rectangle(-BALL_SIZE, height / 2, 1, height, {isStatic: true,});const rightWall = Matter.Bodies.rectangle(width - BALL_SIZE,height / 2,1,height * 2,{isStatic: true,});// Add the walls to the worldMatter.World.add(world, [bottomWall, topWall, leftWall, rightWall]);
Firing a ball projectile
Now that we can add as many bricks as we want, let’s add a ball that can make them explode across the screen! The type interface for the ball is much simpler, since we don’t need to account for the circle’s rotation.
type BallCoords = {x: SharedValue<number>;y: SharedValue<number>;radius: number;type: "ball";}
We then add this type to the GameObjects type we defined earlier.
type GameObjects = BoxCoords | BallCoords;
Finally, we create our ball above the component and add it to the world. Since we only have one ball, but potentially multiple bricks, it's best to create the ball outside the render cycle to avoid unnecessary re-renders.
// Create the Ballconst ballCoords: BallCoords = {x: makeMutable(width * 0.15),y: makeMutable(height * 0.85),radius: BALL_SIZE,type: "ball",};const ball = Matter.Bodies.circle(ballCoords.x.value,ballCoords.y.value,ballCoords.radius,{isSleeping: true,restitution: 0.9,});// Add it to the worldMatter.World.add(world, [bottomWall, topWall, leftWall, rightWall, ball]);export default function App() {// Add them to the initial values in the game objectsconst [elements, setElements] = useState<GameObjects[]>([ballCoords]);const boxesWorld = useRef<Matter.Body[]>([ball]);
To allow the ball to move, we need to set its acceleration and angular velocity. However, we don’t want these properties to take effect until the ball is actually launched. Fortunately, Matter.Body can handle most of this for us. We'll just need to put the ball to “sleep” so it stays inactive until we're ready to launch it. Remember to add this function to the onLaunch in the Buttons component.
function launchBall() {Sleeping.set(ball, false);Matter.Body.setAngularVelocity(ball, 5);Matter.Body.setVelocity(ball, { x: 60, y: 0 });}
At this point, you’ll no doubt want to render your ball. We can do this through a simple if statement in the canvas
{elements.map((box, i) => {if (box.type === "box") {return (// return the rectangle from earlier);} else {return (<Circlekey={i}cx={ballCoords.x}cy={ballCoords.y}r={ballCoords.radius}color="limegreen"/>);}}
Finally, we can edit our physics function to track the movements of the ball. After this, you should be able to press the button to start the game.
function update() {Matter.Engine.update(engine, 1000 / 60);animationFrame = requestAnimationFrame(update);elements.forEach((element, i) => {if (element.type === "box") {/// box logic from earlier} else if (element.type === "ball") {element.x.value = boxesWorld.current[i].position.x + BALL_SIZE;element.y.value = boxesWorld.current[i].position.y + BALL_SIZE;}});};
Final thoughts
I hope you enjoyed this article on how to add game physics to your React-Native Skia app! Although Matter.js and Skia weren’t made with each other in mind, it's exciting how simple it is to integrate them with each other.
By the way, if you had some trouble with this article, I also have a video about this on YouTube if you prefer a visual explanation of things.


