A reusable, framework-agnostic and renderer-agnostic TypeScript engine for real-time physics, mesh slicing (Fruit Ninja–style), material-driven fracturing, geometry processing, and spatial queries.
Vanilla Slice is not a game — it's the engine underneath one. Games, demos, and other apps are consumers of the engine.
- Headless core — no DOM, no browser APIs, no rendering, no framework. Runs anywhere JavaScript runs.
- Rendering is an adapter — Three.js is the first adapter, never a requirement. The engine owns simulation state; renderers only read it.
- Composable packages — a small, strongly-typed public API you can adopt
piece by piece, published under the
@vanilla-slice/*scope.
import { createWorld, createBox } from '@vanilla-slice/core';
const world = createWorld({ gravity: [0, -9.81, 0] });
world.spawn({ geometry: createBox(1, 1, 1), position: [0, 5, 0], mass: 1 });
// Drive the fixed-timestep simulation from your own loop.
world.update(1 / 60);- Rigid-body physics — fixed-timestep, symplectic integration, gravity, linear/angular damping, and bounds cleanup.
- Collisions on generalised meshes — convex-hull colliders with GJK + EPA narrow-phase, face-clipping contact manifolds, and a sequential-impulse solver (restitution + Coulomb friction, linear and angular response). Immovable bodies via infinite mass; optional approximate convex decomposition for concave shapes.
- Bounded mesh slicing — a slice is a bounded interaction volume, never an infinite plane. Pluggable regions: sphere, cylinder, box, or unbounded. Produces closed fragments with separation impulses.
- Data-driven materials — a
Material(density, friction, restitution, toughness, brittleness, fracture propagation) referenced by aMaterialRefcomponent. Behaviour follows from the data — "fruit slices, glass fractures, steel resists" — never from object-type branching. - Interaction framework — slice, fracture, and impact are stateless
processors dispatched by one
InteractionSystemover a per-step event queue; new interactions are added as processors, not new top-level systems. - Real-time fracture — Voronoi fragment generation (deterministic and neighbour-limited), collision- and slice-driven and gated by material thresholds. Produces closed convex fragments with radial impulses.
- Spatial broad-phase — a uniform spatial hash for region/neighbour/pair queries; avoids O(n²) scans.
- Zero-dependency math — vectors, matrices, and quaternions.
- Deterministic-where-feasible, minimal per-frame allocations, scalable to hundreds of objects.
Dependencies flow one way: consumers depend on adapters/runtime, which depend on the framework-free core.
flowchart LR
math --> geometry
math --> physics
math --> spatial
geometry --> slicing
spatial --> slicing
geometry --> fracture
spatial --> fracture
materials --> interactions
physics --> interactions
geometry --> interactions
spatial --> interactions
physics --> core
slicing --> core
fracture --> core
interactions --> core
materials --> core
spatial --> core
geometry --> core
core --> renderer["renderer-three<br/>(adapter)"]
core --> runtime["runtime<br/>(loop + input)"]
renderer --> apps["apps / your game"]
runtime --> apps
- No framework (Angular/React/…) in the core.
- No Three.js in the core (
math,materials,geometry,physics,spatial,slicing,fracture,interactions,core). - No DOM or browser APIs in the engine — it must run headless.
- No circular dependencies between packages.
- Rendering is an adapter; it reads engine state, never owns simulation state.
- The update loop is engine-driven and imperative — a framework never controls it.
| Package | Description | Depends on |
|---|---|---|
@vanilla-slice/math |
Vectors, matrices, quaternions. | — |
@vanilla-slice/materials |
Data-driven physical material properties (density, toughness, brittleness, …). | — |
@vanilla-slice/geometry |
Mesh representation, plane intersection, convex hulls, mesh splitting, convex decomposition. | math |
@vanilla-slice/physics |
Rigid bodies, integration, collisions (GJK/EPA), impulse solver. | math |
@vanilla-slice/spatial |
Spatial hash / broad-phase queries. | math |
@vanilla-slice/slicing |
Bounded slice volumes, candidate filtering, fragment generation. | geometry, spatial, math |
@vanilla-slice/fracture |
Voronoi fracture fragment generation (sibling of slicing). | geometry |
@vanilla-slice/interactions |
Interaction framework: types, processor registry, per-step event queue. | materials, physics, geometry, spatial, math |
@vanilla-slice/core |
ECS world orchestrating the above — the main entry point. | core packages |
@vanilla-slice/renderer-three |
Three.js rendering adapter + raycasting. | core, math, three (peer) |
@vanilla-slice/runtime |
Framework-agnostic engine loop + swipe-to-slice input. | core, math |
Most apps only need to depend on @vanilla-slice/core (which re-exports a
curated facade), plus renderer-three and runtime if you want the batteries
included.
npm install @vanilla-slice/core @vanilla-slice/renderer-three @vanilla-slice/runtime threethree is a peer dependency of renderer-three, so you control its version.
The packages ship as ESM with type declarations and require Node ≥ 18.
import { createWorld, createBox } from '@vanilla-slice/core';
const world = createWorld({
gravity: [0, -9.81, 0],
collisions: true, // on by default
restitution: 0.2,
friction: 0.5,
});
// A dynamic box…
world.spawn({ geometry: createBox(1, 1, 1), position: [0, 5, 0], mass: 1 });
// …resting on an immovable one (infinite mass).
world.spawn({ geometry: createBox(10, 1, 10), position: [0, 0, 0], mass: 0 });
// Advance with a variable frame delta; physics steps at a fixed timestep.
world.update(1 / 60);
// Snapshot transforms for your renderer to consume.
const state = world.getRenderState();A slice is a cutting plane confined to a bounded region:
import {
createPlane,
fromNormalAndPoint,
sliceVolume,
cylinderRegion,
} from '@vanilla-slice/core';
const plane = fromNormalAndPoint(createPlane(), [0, 1, 0], [0, 0, 0]);
// A cylinder along the camera axis cuts objects at any depth along the swipe.
const volume = sliceVolume(plane, cylinderRegion([0, 0, 0], [0, 0, 1], 1.5));
const { removed, created } = world.slice(volume);Behaviour is data-driven: a Material decides how an object responds. Attach
one by id and the engine derives mass from density, and fractures (or resists) on
hard collisions based on toughness/brittleness — no object-type branching.
import { createWorld, createBox, defineMaterial } from '@vanilla-slice/core';
const world = createWorld({
gravity: [0, 0, 0],
materials: [
defineMaterial('glass', { density: 2.5, toughness: 10, brittleness: 0.85 }),
defineMaterial('steel', { toughness: 1e6 }), // effectively unbreakable
],
});
// Glass shatters into Voronoi fragments when it hits hard enough…
world.spawn({ geometry: createBox(1, 1, 1), material: 'glass', velocity: [8, 0, 0] });
// …against an immovable, non-sliceable steel wall that resists.
world.spawn({
geometry: createBox(0.6, 3, 3),
material: 'steel',
mass: 0,
sliceable: false,
});
world.update(1 / 60); // a hard-enough impact fractures the glass, purely from dataimport { createWorld, createBox } from '@vanilla-slice/core';
import { ThreeRenderer } from '@vanilla-slice/renderer-three';
import { EngineLoop } from '@vanilla-slice/runtime';
import { Scene, PerspectiveCamera, WebGLRenderer, MeshStandardMaterial } from 'three';
const world = createWorld({ gravity: [0, -9.81, 0] });
world.spawn({ geometry: createBox(1, 1, 1), meshRef: 'crate', position: [0, 3, 0] });
const scene = new Scene();
const camera = new PerspectiveCamera(55, 1, 0.1, 100);
const gl = new WebGLRenderer();
const renderer = new ThreeRenderer({
scene,
createMaterial: () => new MeshStandardMaterial({ color: 0x40c4ff }),
});
const loop = new EngineLoop((dt) => {
world.update(dt);
renderer.sync(world);
gl.render(scene, camera);
});
loop.start();Two showcase apps live in apps/ and consume the engine like any other
app (they own their own imperative loop):
npx nx serve @vanilla-slice/slicing-game # http://localhost:5173
npx nx serve @vanilla-slice/spinning-slices # http://localhost:5174- slicing-game — a Fruit Ninja–style swipe-to-slice game.
- spinning-slices — a slicing showcase that also demonstrates collision-driven fracture: glass cubes shatter against an immovable steel slab, driven entirely by material data.
Production bundles:
npx nx bundle @vanilla-slice/slicing-game # -> apps/slicing-game/dist-web
npx nx preview @vanilla-slice/slicing-gameThis is an Nx monorepo using npm workspaces.
npm install # install everything
npx nx run-many -t build # build all packages
npx nx run-many -t test # run all tests (Vitest)
npx nx run-many -t lint # lint + enforce module boundaries
npx nx graph # visualise the dependency graphDependency-boundary rules (enforced by ESLint) keep the layering honest — e.g.
physics can only depend on math, and nothing may import a rendering adapter
into the core.
Releases are automated with nx release and Conventional Commits. On a push
to master, the version is computed from commit messages (fix: → patch,
feat: → minor), the changelog and tag are generated, and the packages are
published to npm. While the engine is 0.x, treat breaking changes as feat:
(minor) — see ADR 0008.
MIT © Rohan Fredriksson