@vorshim92/character-controller
v0.1.0
Published
Thin, backend-agnostic third-person character controller for React Three Fiber. Currently backed by BVHEcctrl.
Readme
@vorshim92/character-controller
A thin, backend-agnostic third-person character controller for React Three Fiber — multi-character and network-ready.
It is intentionally not a from-scratch physics implementation. The hard part (collide-and-slide, grounded detection, slopes, steps, floating capsule, moving platforms) comes from a vendored, per-instance fork of BVHEcctrl — no physics engine, three-mesh-bvh for collision.
What this package owns is the integration boundary: a small, stable public
API (<CharacterController> + a typed imperative handle) that your projects
depend on, so the backend stays an implementation detail you can swap or extend
later without touching consumers.
Status: v0, pre-release. The public surface is deliberately minimal and will be hardened only after it has been driven by two real projects.
Requirements
Effectively React 19+:
react/react-dom>= 19.1three>= 0.177@react-three/fiber>= 8 (realistically 9, given React 19)@react-three/drei>= 9
Install
npm install @vorshim92/character-controller three @react-three/fiber @react-three/dreithree-mesh-bvh and zustand are direct dependencies of this package, while
three / @react-three/* are peer dependencies — install them once in your
app so there is a single copy of three in the tree.
Usage
import { Canvas } from "@react-three/fiber";
import { KeyboardControls } from "@react-three/drei";
import { CharacterController, StaticCollider } from "@vorshim92/character-controller";
const keyboardMap = [
{ name: "forward", keys: ["ArrowUp", "KeyW"] },
{ name: "backward", keys: ["ArrowDown", "KeyS"] },
{ name: "leftward", keys: ["ArrowLeft", "KeyA"] },
{ name: "rightward", keys: ["ArrowRight", "KeyD"] },
{ name: "jump", keys: ["Space"] },
{ name: "run", keys: ["Shift"] },
];
export default function App() {
return (
<KeyboardControls map={keyboardMap}>
<Canvas shadows>
<StaticCollider>
<YourMap />
</StaticCollider>
<CharacterController debug position={[0, 2, 0]} capsuleRadius={0.3} capsuleLength={0.6}>
<YourCharacterModel />
</CharacterController>
</Canvas>
</KeyboardControls>
);
}position / rotation set the spawn transform, capsuleRadius /
capsuleLength size the collider to your model (total height =
length + 2 * radius). All four are mount-time only — move the character
via the handle afterwards.
Why does the character hang in the air right after load? The simulation waits
startDelayseconds (default 1.5) before starting, to give the BVH collision geometry time to build — until then the character is frozen at its spawn position, then gravity kicks in and it drops. For small scenes setstartDelay={0.3}and spawn close to the ground; for heavy maps keep it high enough that the character doesn't fall through the floor on the first simulated frames.
Driving it imperatively (gamepad / AI / multiplayer)
Built-in keyboard works out of the box via KeyboardControls. For anything else
— a gamepad, an AI agent, or remote state coming from a server — grab the handle
and feed it intent each frame. Set keyboardInput={false} so the character
ignores local key presses.
setMovement has full-state semantics: each call is the complete input
state for that moment, and omitted fields mean released. Send your input
state every frame; nothing latches. setMovement({ jump: true }) for one frame
is one jump, not an infinite bunny-hop.
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { Vector3, Quaternion } from "three";
import {
CharacterController,
type CharacterControllerHandle,
type CharacterStatus,
} from "@vorshim92/character-controller";
function Player() {
const ref = useRef<CharacterControllerHandle>(null);
// Preallocate the status target: getStatus(target) copies into it,
// so polling at 60fps allocates nothing.
const status = useRef<CharacterStatus>({
position: new Vector3(),
quaternion: new Quaternion(),
linvel: new Vector3(),
isOnGround: false,
isOnMovingPlatform: false,
animation: "IDLE",
});
useFrame(() => {
ref.current?.setMovement({ forward: true, run: true });
const { position, isOnGround } = ref.current!.getStatus(status.current);
// ...follow camera, trigger SFX on landing, etc.
});
// Example one-off impulses:
// ref.current?.addVelocity(new Vector3(0, 6, 0)); // double jump
// ref.current?.setVelocity(new Vector3(10, 0, 0)); // dash
// ref.current?.teleport(new Vector3(0, 1, 0)); // respawn / server correction
return (
<CharacterController ref={ref} keyboardInput={false}>
<YourCharacterModel />
</CharacterController>
);
}Coordinate convention
The controller's origin — what the position prop sets, teleport() moves and
CharacterStatus.position reports — is the capsule center. Standing on
flat ground it sits capsuleLength / 2 + capsuleRadius + floatHeight above the
floor (0.8 with the defaults), so place your model with its feet offset down by
that amount.
Multiple characters
Every handle is per-instance: mount as many <CharacterController>s as you
need (player + NPCs), and each getStatus() / useCharacterAnimation() reads
its own character. The example playground runs a keyboard player and a scripted
patrol side by side with independent grounded/animation state.
Networking
The library is transport-agnostic — bring your own WebSocket/WebRTC. The pattern:
- Sending side (the simulated player):
handle.getSnapshot()returns a JSON-safeCharacterStatusSnapshot(plain numbers, no three.js classes). Serialize it and put it on the wire at your tick rate. - Receiving side (everyone else's view of that player): render a
<RemoteCharacter>— a kinematic visual with no local simulation — and callsetTarget(packet)whenever a packet arrives. It smooths toward the latest target every frame (smoothTime, default 0.1s), so a 10Hz feed still looks fluid; gaps larger thansnapDistance(default 5m) teleport instead of gliding.
function RemotePlayer({ socket }: { socket: WebSocket }) {
const ref = useRef<RemoteCharacterHandle>(null);
useEffect(() => {
socket.onmessage = (e) => ref.current?.setTarget(JSON.parse(e.data));
}, [socket]);
return (
<RemoteCharacter ref={ref}>
<YourCharacterModel />
</RemoteCharacter>
);
}MovementInput is also JSON-safe, so for authoritative setups you can ship
intent to the server, simulate there, and broadcast snapshots back.
Body blocking (optional)
By default characters pass through each other. Opt into capsule-vs-capsule collision per character:
<CharacterController bodyBlock>...</CharacterController>
<CharacterController bodyBlock={{ mode: "hard" }}>...</CharacterController>
<RemoteCharacter bodyBlock>...</RemoteCharacter>mode: "soft" (default) separates overlapping characters at a capped speed
(strength, default 6 m/s) — forgiving when several pile up. mode: "hard"
separates instantly and removes the velocity pointing into the other character
(wall-style). Two movable characters split the overlap by weight;
<RemoteCharacter> registers as immovable — it blocks and pushes local
characters but is never displaced, since its position belongs to the server
feed (which stays authoritative for real collision resolution). Vertical
ranges are respected, so jumping over heads keeps working. The resolution is
positional and bounded by the actual overlap — it cannot fling characters.
Reacting to animation state
import { useEffect } from "react";
import { useCharacterAnimation } from "@vorshim92/character-controller";
function AnimationDriver() {
const anim = useCharacterAnimation(); // "IDLE" | "WALK" | "RUN" | "JUMP_*"
useEffect(() => {
// crossfade your AnimationMixer clip here
}, [anim]);
return null;
}The hook is per-character: call it from a component rendered inside a
<CharacterController> or <RemoteCharacter> (typically your model component)
and it tracks that character's state. The same model component works under
both. Per-frame data (position, velocity, grounded) is read imperatively via
getStatus() inside useFrame on purpose — you don't want a React re-render
at 60fps.
Architecture
src/
index.ts Public API barrel — the entire package surface.
types.ts The boundary contract (handle, props, status, input).
Controller.tsx Public <CharacterController>; delegates to a backend.
RemoteCharacter.tsx Network-driven kinematic visual (no simulation).
useCharacterController.ts Reactive per-character animation hook.
animationContext.ts Internal: distributes the per-character animation store.
backends/
bvh.tsx Maps the vendored backend onto types.ts.
vendor/bvhecctrl/ Vendored fork of BVHEcctrl (see UPSTREAM.md).The rule: everything depends on types.ts. Only backends/bvh.tsx and the
vendor directory know the backend exists. Adding a second backend (e.g.
Rapier's KinematicCharacterController) means writing backends/rapier.tsx that
satisfies the same CharacterControllerHandle, then branching in
Controller.tsx.
Third-party code
src/vendor/bvhecctrl/ is a vendored fork of
BVHEcctrl by Erdong Chen, MIT licensed
(license preserved in src/vendor/bvhecctrl/LICENSE). It was vendored to make
character status per-instance — upstream reports all instances into one global
object — plus a handful of compile-only fixes. Every local change is logged in
src/vendor/bvhecctrl/UPSTREAM.md against the pinned upstream commit, so
future upstream merges remain a mechanical diff-and-replay.
Roadmap / non-goals
- World physics is a separate, optional layer, not part of this. A character controller moves one capsule through static/kinematic geometry. Stackable crates, ragdolls, vehicles, joints, two-way mass interaction — that is a physics engine (e.g. Rapier) running alongside, added only when a project needs it. The two coexist; they are not a backend swap. (Vehicles in particular are NOT a character-controller problem.)
- Second backend (Rapier KCC): not now. It is a true swap (same job, different substrate) and will be added when a real use case demands it — and the shared interface derived from two concrete projects, not designed speculatively.
- Characters are not world colliders. Character ↔ character collision is handled by the dedicated opt-in body-block layer (see above), NOT by registering characters as kinematic world colliders (which would give them platform semantics: friction, carrying, velocity transfer).
- Networking helpers (snapshot buffers, reconciliation): deferred until a
real networked project drives the requirements. The wire types and
<RemoteCharacter>are the stable foundation. - Touch UI (joystick/buttons): not vendored. Upstream's
<Joystick>/<VirtualButton>can be reintroduced if a mobile project needs them. - Camera is a separate concern. Use drei's
CameraControls/PointerLockControlsor your own rig; the example ships a trivial follow camera.
Development
npm install
npm run dev # live playground (example/)
npm run typecheck
npm run build # builds the library into dist/The playground is a full test arena: keyboard player, scripted patrol NPC and a
fake 10Hz "remote" player — all three using the same animated character model
(dev-only asset, see public/models/CREDITS.md) — plus ramps at 20/35/50°,
stairs with increasing rises, a collide-and-slide funnel, jump platforms and
two moving platforms (KinematicCollider). A DOM HUD shows each character's
live getSnapshot() at 10Hz, and a leva
panel tunes movement props at runtime (capsule props remount the player —
they're mount-time only) and switches between the follow camera and a free
orbit camera. A kill-floor teleports anyone falling below y=-15 back to spawn.
License
MIT. Vendored BVHEcctrl code: MIT, © Erdong Chen (see
src/vendor/bvhecctrl/LICENSE).
