npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.1
  • three >= 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/drei

three-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 startDelay seconds (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 set startDelay={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:

  1. Sending side (the simulated player): handle.getSnapshot() returns a JSON-safe CharacterStatusSnapshot (plain numbers, no three.js classes). Serialize it and put it on the wire at your tick rate.
  2. Receiving side (everyone else's view of that player): render a <RemoteCharacter> — a kinematic visual with no local simulation — and call setTarget(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 than snapDistance (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/PointerLockControls or 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).