A 2D (and soon 3D) game engine for React Native built to run inside Expo Go — no custom native modules, no expo prebuild, no config plugins.
- ECS core — integer entity ids, TypedArray-backed component buffers, bitmask queries
- Two-loop architecture — fixed-timestep simulation on the JS thread, render loop on the UI thread
- Skia renderer — single
<Canvas>driven by Reanimated worklets; no React reconciliation per frame - Physics — custom circles + AABB solver tailored to arcade games (no rotation, no joints, ~5–10× faster than a JS Box2D port for stacked-circle workloads)
- Asset pipeline — bundled (
require) and remote (URL) assets behind one Suspense-friendly API - Input — Gesture Handler's modern Gesture API on the UI thread
Status: 0.1.x — pre-1.0. APIs may shift. Phase 1 (2D) is functionally complete and dogfooded in the demo app in this repo. Phase 2 (
expo-gl+ three.js) lands behind the same engine core without a rewrite.
yarn add @onlynative/game-engine
npx expo install \
@shopify/react-native-skia \
expo-asset \
expo-file-system \
react-native-gesture-handler \
react-native-reanimated \
react-native-workletsAll of the above are peer dependencies — your Expo app owns the SDK version. Keep react-native-worklets/plugin as the last plugin in babel.config.js:
// babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-worklets/plugin'],
};
};Wrap your app root with GestureHandlerRootView (Gesture Handler requirement).
import { useMemo } from 'react';
import { useDerivedValue } from 'react-native-reanimated';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useImageAsTexture } from '@shopify/react-native-skia';
import {
GameEngine,
addComponent,
createEntity,
createPhysics,
createWorld,
defineComponent,
type System,
} from '@onlynative/game-engine';
import { SkiaRenderer, loadSkiaAtlas, type SkiaAtlas } from '@onlynative/game-engine/renderers/skia';
const world = createWorld({ capacity: 1024 });
const Position = defineComponent(world, { x: 'f32', y: 'f32' });
const Velocity = defineComponent(world, { x: 'f32', y: 'f32' });
const Sprite = defineComponent(world, { atlas: 'u32', frame: 'u16', tint: 'u32' });
const physics = createPhysics({
world,
position: Position,
velocity: Velocity,
gravity: { x: 0, y: 600 },
});
const id = createEntity(world);
addComponent(world, id, Sprite, { atlas: 0, frame: 0, tint: 0xffffffff });
physics.attach(id, {
type: 'dynamic',
position: { x: 100, y: 50 },
velocity: { x: 0, y: 0 },
shape: { kind: 'circle', radius: 8 },
});
export default function App() {
const atlases = useSharedValue<ReadonlyArray<SkiaAtlas>>([]);
useEffect(() => {
loadSkiaAtlas(require('./assets/ball.png')).then((a) => {
atlases.value = [a];
});
}, [atlases]);
const systems = useMemo<ReadonlyArray<System>>(() => [physics.step], []);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<GameEngine
world={world}
systems={systems}
renderer={
<SkiaRenderer
world={world}
position={Position}
sprite={Sprite}
atlases={atlases}
/>
}
/>
</GestureHandlerRootView>
);
}| Import | Contains |
|---|---|
@onlynative/game-engine |
ECS, world, loop, <GameEngine>, physics, asset loader |
@onlynative/game-engine/renderers/skia |
<SkiaRenderer>, SkiaAtlas, loadSkiaAtlas, gridFrames |
The renderer split is structural: phase 2 will add @onlynative/game-engine/renderers/three, which depends on expo-gl and expo-three. Keeping renderers behind subpath imports means consumers never pay for a renderer they don't use.
- Expo Go only on the supported path. Custom dev builds work too, but the engine deliberately avoids anything that would force one.
- Hermes is the JS engine. No WebAssembly. That rules out
rapier/box2d-wasm-style physics. - Single-touch input today. Gesture pointer ids are hardcoded to
0— multi-touch is on the roadmap. - Naive O(N²) broadphase. Comfortable up to ~200 dynamic bodies; spatial-hash broadphase will land before pushing past that.
MIT