Custom Character Controller

Building a custom humanoid character controller in the style of fortnite

In this tutorial, you’ll build a custom extensible humanoid character controller in a fortnite style with support for aim, shoot, reload, run, and jump, using @react-three/viverse and @react-three/timeline.

What you’ll build

  • Third-person character with physics and camera behavior
  • WASD movement, run, jump, mouse look (pointer lock)
  • Aiming up/forward/down with upper-body blending
  • Pistol idle/shoot/reload with sound and muzzle flash
  • Camera FOV and zoom effects while running/aiming
  • Map collisions using a BVH physics body
  • Simple HUD with name, health bar, ammo, and crosshair

Prereqs and install

Use pnpm and ensure these packages are installed. VRM support is optional but recommended for humanoids.

pnpm add three @react-three/fiber @react-three/drei @react-three/timeline @react-three/viverse zustand

Create an environment file for your Viverse app id (replace the value with your own):

# .env.local
VITE_VIVERSE_APP_ID=your_app_id_here

Notes:

  • Keep your assets (e.g. map.glb, avatar.vrm, sounds/textures) under public/.
  • Pointer lock: click the canvas to lock the mouse; press ESC to release.

Step 1 — App shell (scene, provider, UI)

First, we start by copying all the assets, we'll need from this repository under examples/fortnite/public into your public folder. For animations, we are using the Universal Animation Library - Pro from Quaternius. If you want to use these animations for your project, please make sure to buy the Pro version at their website.

Next, we start adding a <Canvas> and <Viverse> component, a environment that includes a sky, clouds, fog, lights, a HUD overlay, the character, and the map. In the next steps, we will create the Map, Character, and HUD components. We use <Viverse> up front so profile features, shared actions, and integrations are available everywhere; you could delay it, but then you’d pass more props around later.

Key ideas:

  • Provider: <Viverse> enables account/profile APIs and shared actions/state.
  • Suspense Fallback: Show a simple “Loading...” UI.
  • Environment: Sky, Clouds, directionalLight with shadows.
  • Composition: <Character /> and <Map /> live inside the canvas.

Add the following snippet into your src/app.tsx:

return (
  <Viverse clientId={import.meta.env.VITE_VIVERSE_APP_ID}>
    <HUD />
    <Canvas style={{ width: '100%', flexGrow: 1 }} shadows gl={{ antialias: true, localClippingEnabled: true }}>
      <fog attach="fog" args={[0xd3e1ec]} near={12} far={60} />
      <Sky rayleigh={0.2} turbidity={0.6} sunPosition={[9.2, 9, 5]} />
      <Suspense fallback={null}>
        <Clouds material={MeshBasicMaterial}>
          <Cloud position-y={40} segments={40} bounds={[50, 1, 50]} volume={20} color="gray" />
          <Cloud position-y={60} segments={40} bounds={[20, 5, 20]} volume={20} color="gray" />
        </Clouds>
        <directionalLight /* with shadows */ />
        <ambientLight intensity={1} />
        <Character />
        <Map />
      </Suspense>
    </Canvas>
  </Viverse>
)

Step 2 — Map and collisions

Create src/map.tsx and add the full component below. It loads the map, enables collisions via BvhPhysicsBody, and tweaks the ground material to receive shadows properly.

export function Map() {
  const [map, setMap] = useState<Group | null>(null)
  useEffect(
    () =>
      map?.traverse(
        (object) =>
          object.name === 'Plane' &&
          object instanceof Mesh &&
          ((object.receiveShadow = true),
          (object.material = new MeshStandardMaterial({
            roughness: 1,
            metalness: 0,
            map: (object.material as MeshStandardMaterial).map,
          }))),
      ),
    [map],
  )
  return (
    <BvhPhysicsBody>
      <Gltf ref={setMap} scale={0.3} src="map.glb" />
    </BvhPhysicsBody>
  )
}

Notes:

  • Wrapping the map in BvhPhysicsBody builds a BVH acceleration structure, necessary for performing collision detections.
  • We set receiveShadow = true on the ground mesh and re-create the material to ensure proper PBR/shadowing with the embedded base color map.
  • Keep your map.glb in public/ so it’s served statically by Vite.
  • If you wonder why we load via src="map.glb" instead of import, it keeps asset paths simple for Vite and mirrors how the rest of the example serves content.

Step 3 — The Character component

Next, we create the Character component, which uses a VRM model (avatar.vrm), displays the pistol attached to the right hand (pistol.glb), adds animations (jogging, aiming, pistol actions), and sets up audio and visual effects. We prefer VRM for humanoids because it standardizes bone names (handy for retargeting); plain glTF works too if your bone names match the boneMap.

Before creating the component, we need to create several custom actions, specifically reloading, shooting, and aiming actions outside of the character component.

export const ReloadAction = new EventAction()
export const ShootAction = new EventAction()
export const AimAction = new StateAction<boolean>(BooleanOr, false)

Next, we create the character component and start by loading the character model and setting its height to the spawn height.

const model = useCharacterModelLoader({ castShadow: true, url: 'avatar.vrm' })
useEffect(() => void (model.scene.position.y = 70), [model])

We set the spawn height once so the character drops onto the level instead of intersecting it. Next, we set up physics on the character and apply the movement actions to the physics. We use the built-in helper rather than writing raw velocity math to keep input → motion deterministic and consistent with other examples.

const physics = useBvhCharacterPhysics(model.scene)
useFrame((state) => updateSimpleCharacterVelocity(state.camera, physics))

Then, we bind all actions to the inputs (keyboard and mouse). Notice, that we have not yet added a camera behavior or used the custom actions, so even though they are bound, they have no effect yet.

// action bindings
usePointerLockRotateZoomActionBindings()
useKeyboardLocomotionActionBindings({ requiresPointerLock: true })
useKeyboardActionBinding(ReloadAction, { keys: ['KeyR'], requiresPointerLock: true })
usePointerButtonActionBinding(ShootAction, { buttons: [0], requiresPointerLock: true })
usePointerButtonActionBinding(AimAction, { buttons: [2], requiresPointerLock: true })

Render the character and attach the pistol so you can already walk around. We will create the animation components, specifically LowerBodyAnimation, SpineAnimation, UpperBodyAimAnimation, UpperBodyAdditiveAnimation later.

<CharacterModelProvider model={model}>
  <LowerBodyAnimation physics={physics} />
  <SpineAnimation />
  <UpperBodyAimAnimation />
  <UpperBodyAdditiveAnimation />
  {/* pistol and model */}
  <CharacterModelBone bone="rightHand">
    <Gltf scale={0.13} position={[0.1, -0.03, 0]} rotation-x={-Math.PI / 2} src="pistol.glb" />
  </CharacterModelBone>
  <primitive object={model.scene} />
</CharacterModelProvider>

Step 3.1 — Create ammo store

We’ll track ammo with a tiny Zustand store so reload/shoot actions can update it and the HUD can display it.

Add this for example in src/app.tsx or in a small src/state.ts file:

import { create } from 'zustand'

export const useAmmo = create(() => ({ ammo: 12 }))

Step 4 — Camera behavior and rotation sync

We use the character camera behavior and keep the character’s yaw aligned with the camera. Using the provided behavior avoids re‑implementing orbit/zoom/offset logic; if you need full control later, you can swap it for your own.

const cameraBehaviorRef = useCharacterCameraBehavior(model.scene, {
  zoom: { speed: 0 },
  characterBaseOffset: [0.5, 1.3, 0],
})

// character rotation matches camera Y
useFrame((state) => (model.scene.rotation.y = state.camera.rotation.y))

Step 5 — Camera effects: FOV while running, zoom while aiming

Two small hooks manage the cinematic feel. We apply gentle, framerate‑independent easing to avoid jarring changes:

FOV while running: When RunAction is active, we increase the camera’s field of view (from 60 → 75) to convey speed. We use exponential smoothing (t = 1 - exp(-k * delta)) so the transition feels responsive yet stable at any framerate. After changing fov, we call updateProjectionMatrix() to apply it.

function useCameraFovControl() {
  useFrame((state, delta) => {
    if ('fov' in state.camera) {
      const targetFov = RunAction.get() ? 75 : 60
      const t = 1 - Math.exp(-10 * delta)
      state.camera.fov += (targetFov - state.camera.fov) * t
      state.camera.updateProjectionMatrix?.()
    }
  })
}

Zoom while aiming: We adjust the CharacterCameraBehavior’s zoomDistance (2.0 → 0.7) while the AimAction is active. This narrows composition around the crosshair and subtly reduces parallax. We use a slightly faster smoothing constant for snappier aim‑down‑sights behavior.

function useAimZoomControl(behaviorRef: RefObject<CharacterCameraBehavior | undefined>) {
  useFrame((_state, delta) => {
    const behavior = behaviorRef.current
    if (behavior == null) return
    const targetDistance = AimAction.get() ? 0.7 : 2.0
    const t = 1 - Math.exp(-20 * delta)
    behavior.zoomDistance += (targetDistance - behavior.zoomDistance) * t
  })
}

Step 6 — Bone map and masks

We provide a bone map for retargeting and define a mask for “upper body without spine”.

Why exclude the spine?

  • We manually control the spine rotation in Step 8 to keep the torso aiming consistently forward inline with the cross-hair. If the aim layer also animated the spine, it would fight our manual rotation.
  • Therefore, the “upper body without spine” mask includes shoulders, arms, hands, chest, etc., but excludes the spine so we can drive it explicitly.
  • And because rigs vary, the boneMap lets us translate from your model’s bone names to VRM’s, so timeline clips target the right joints.
export const upperBodyWithoutSpine = (name: VRMHumanBoneName) => upperBody(name) && name !== 'spine'
export const boneMap: Record<string, VRMHumanBoneName> = {
  'DEF-hips': 'hips',
  'DEF-spine001': 'spine',
  // ... full mapping in file
}

Step 7 — Lower-body locomotion and jumping

For the lower body, which represents the character’s movement and jumping, we blend eight move directions plus idle, then add a jump state machine. This uses @react-three/timeline graphs and viverse helpers.

Concepts:

  • Compute normalized input direction from actions.
  • Scale animation speed when running.
  • Jump state machine: start → loop → land → move.

Create src/lower-body-animation.tsx. We’ll build it in small parts so each piece is clear and easy to place. This timeline approach keeps animation state readable and composable, compared to ad‑hoc useFrame toggles.

  1. Setup: compute input direction and time scaling

We convert the action values into a normalized 2D direction (for selecting locomotion clips) and speed up locomotion when running.

export function LowerBodyAnimation({ physics }) {
  const normalizedDirection = useMemo(() => new Vector2(), [])
  useFrame(() =>
    normalizedDirection
      .set(MoveRightAction.get() - MoveLeftAction.get(), MoveForwardAction.get() - MoveBackwardAction.get())
      .normalize(),
  )

  const forwardRef = useRef(null)
  const backwardRef = useRef(null)
  const leftRef = useRef(null)
  const rightRef = useRef(null)
  const forwardRightRef = useRef(null)
  const forwardLeftRef = useRef(null)
  const backwardRightRef = useRef(null)
  const backwardLeftRef = useRef(null)

  useFrame(() => {
    const timeScale = RunAction.get() ? 2 : 1
    for (const ref of [
      forwardRef,
      backwardRef,
      leftRef,
      rightRef,
      forwardRightRef,
      forwardLeftRef,
      backwardRightRef,
      backwardLeftRef,
    ]) {
      ref.current && (ref.current.timeScale = timeScale)
    }
  })
  // ...
}
  1. Timeline scaffold and movement state (add this inside the return)

We use a small timeline graph:

  • RunTimeline evaluates the timeline every frame.
  • CharacterAnimationLayer groups animation actions for a specific body region.
  • Graph declares states and transitions.
  • GrapthState is a named state node with transition rules.
return (
  <RunTimeline>
    <CharacterAnimationLayer name="lower-body">
      <Graph enterState="move">
        <GrapthState
          name="move"
          transitionTo={{
            jumpStart: { whenUpdate: () => shouldJump(physics, lastJumpTimeRef.current) },
            jumpLoop: { whenUpdate: () => !physics.isGrounded },
          }}
        >
          {/* Add the directional Switch in substep 2a below */}
        </GrapthState>
        {/* jump states below */}
      </Graph>
    </CharacterAnimationLayer>
  </RunTimeline>
)

2a) Directional clip selection (Switch)

Inside the move state, we select one of eight directional clips plus idle based on the normalized input. The scaleTime values are tuned so diagonals feel consistent with straight movement.

<Switch>
  <SwitchCase index={0} condition={() => Math.abs(normalizedDirection.x) < 0.5 && normalizedDirection.y > 0.5}>
    <CharacterAnimationAction
      mask={lowerBody}
      sync
      scaleTime={1.5}
      boneMap={boneMap}
      ref={forwardRef}
      url="jog-forward.glb"
    />
  </SwitchCase>
  <SwitchCase index={1} condition={() => normalizedDirection.x > 0.5 && normalizedDirection.y > 0.5}>
    <CharacterAnimationAction
      mask={lowerBody}
      sync
      scaleTime={1.5}
      boneMap={boneMap}
      ref={forwardRightRef}
      url="jog-forward-right.glb"
    />
  </SwitchCase>
  <SwitchCase index={2} condition={() => normalizedDirection.x > 0.5 && Math.abs(normalizedDirection.y) < 0.5}>
    <CharacterAnimationAction
      mask={lowerBody}
      sync
      scaleTime={0.9}
      boneMap={boneMap}
      ref={rightRef}
      url="jog-right.glb"
    />
  </SwitchCase>
  <SwitchCase index={3} condition={() => normalizedDirection.x > 0.5 && normalizedDirection.y < -0.5}>
    <CharacterAnimationAction
      mask={lowerBody}
      sync
      scaleTime={1.3}
      boneMap={boneMap}
      ref={backwardRightRef}
      url="jog-backward-right.glb"
    />
  </SwitchCase>
  <SwitchCase index={4} condition={() => Math.abs(normalizedDirection.x) < 0.5 && normalizedDirection.y < -0.5}>
    <CharacterAnimationAction
      mask={lowerBody}
      sync
      scaleTime={1.4}
      boneMap={boneMap}
      ref={backwardRef}
      url="jog-backward.glb"
    />
  </SwitchCase>
  <SwitchCase index={5} condition={() => normalizedDirection.x < -0.5 && normalizedDirection.y < -0.5}>
    <CharacterAnimationAction
      mask={lowerBody}
      sync
      scaleTime={1.3}
      boneMap={boneMap}
      ref={backwardLeftRef}
      url="jog-backward-left.glb"
    />
  </SwitchCase>
  <SwitchCase index={6} condition={() => normalizedDirection.x < -0.5 && Math.abs(normalizedDirection.y) < 0.5}>
    <CharacterAnimationAction
      mask={lowerBody}
      sync
      scaleTime={0.9}
      boneMap={boneMap}
      ref={leftRef}
      url="jog-left.glb"
    />
  </SwitchCase>
  <SwitchCase index={7} condition={() => normalizedDirection.x < -0.5 && normalizedDirection.y > 0.5}>
    <CharacterAnimationAction
      mask={lowerBody}
      sync
      scaleTime={1.5}
      boneMap={boneMap}
      ref={forwardLeftRef}
      url="jog-forward-left.glb"
    />
  </SwitchCase>
  <SwitchCase index={8}>
    <CharacterAnimationAction mask={lowerBody} url={IdleAnimationUrl} />
  </SwitchCase>
</Switch>
  1. Jump states

Jumping splits into short phases so we can apply upward velocity once, loop while airborne, and land smoothly when grounded again.

const lastJumpTimeRef = useRef(0)

/* place inside <Graph> after the "move" state */
<GrapthState name="jumpStart" transitionTo={{ jumpDown: { whenUpdate: () => !physics.isGrounded }, finally: 'jumpUp' }}>
  <CharacterAnimationAction
    until={() => timePassed(0.2, 'seconds')}
    update={() => void physics.inputVelocity.multiplyScalar(0.3)}
    mask={lowerBody}
    paused
    url={JumpUpAnimationUrl}
  />
</GrapthState>
<GrapthState name="jumpLoop" transitionTo={{ jumpDown: { whenUpdate: () => physics.isGrounded } }}>
  <CharacterAnimationAction mask={lowerBody} url={JumpLoopAnimationUrl} />
</GrapthState>
<GrapthState
  name="jumpUp"
  transitionTo={{
    jumpDown: { whenUpdate: (_, _clock, actionTime) => actionTime > 0.3 && physics.isGrounded },
    finally: 'jumpLoop',
  }}
>
  <CharacterAnimationAction
    loop={LoopOnce}
    mask={lowerBody}
    init={() => {
      lastJumpTimeRef.current = performance.now() / 1000
      physics.applyVelocity(new Vector3(0, 8, 0))
    }}
    url={JumpUpAnimationUrl}
  />
</GrapthState>
<GrapthState name="jumpDown" transitionTo={{ finally: 'move' }}>
  <CharacterAnimationAction
    mask={lowerBody}
    until={() => timePassed(150, 'milliseconds')}
    loop={LoopOnce}
    url={JumpDownAnimationUrl}
  />
</GrapthState>

Step 8 — Spine: keep upright and match camera yaw

Create src/spine-animation.tsx. Add these parts:

  1. Resolve the spine bone once

We cache the lowest upper‑body bone so updates are fast and don’t require repeated lookups.

export function SpineAnimation() {
  const model = useCharacterModel()
  const spineBone = useMemo(() => {
    // VRM or plain glTF
    return model instanceof VRM ? model.humanoid.getNormalizedBoneNode('spine') : model.scene.getObjectByName('spine')
  }, [model])
  // ...
}
  1. Align the spine each frame (keep upright, match camera yaw)

The spine should stay upright and rotate only around Y to match camera yaw; this keeps the torso aligned with aim and avoids double transforms.

const eulerYXZ = new Euler(0, 0, 0, 'YXZ')
const qWorld = new Quaternion()
const qParentWorldInv = new Quaternion()
const qLocal = new Quaternion()
const cameraRotationOffsetY = -0.5

useFrame((state) => {
  if (spineBone == null) return
  state.camera.getWorldQuaternion(qWorld)
  eulerYXZ.setFromQuaternion(qWorld, 'YXZ')
  const cameraYaw = eulerYXZ.y + (model instanceof VRM ? 0 : Math.PI) + cameraRotationOffsetY
  eulerYXZ.set(0, cameraYaw, 0, 'YXZ')
  qWorld.setFromEuler(eulerYXZ)
  const parent = spineBone.parent
  if (parent != null) {
    parent.getWorldQuaternion(qParentWorldInv).invert()
    qLocal.copy(qParentWorldInv).multiply(qWorld)
    spineBone.quaternion.copy(qLocal)
  } else {
    spineBone.quaternion.copy(qWorld)
  }
  spineBone.updateMatrixWorld()
})

Step 9 — Aim up/forward/down blending

Create src/upper-body-aim-animation.tsx. Add these parts:

  1. Weight aim clips by camera pitch

We blend “up/forward/down” by the camera’s pitch so the upper body points toward where you look. Using three focused clips keeps pose fidelity better than stretching a single generic clip. We also introduce Parallel, which plays its children at the same time—we’ll reuse it for simultaneous clip layers and effects.

export function UpperBodyAimAnimation() {
  const aimUpRef = useRef(null)
  const aimForwardRef = useRef(null)
  const aimDownRef = useRef(null)

  useFrame((state) => {
    if (!aimUpRef.current || !aimForwardRef.current || !aimDownRef.current) return
    const pitch = -state.camera.rotation.x
    if (pitch <= 0) {
      aimUpRef.current.weight = Math.min(1, Math.max(0, -pitch / (Math.PI / 2)))
      aimForwardRef.current.weight = 1 - aimUpRef.current.weight
      aimDownRef.current.weight = 0
    } else {
      aimDownRef.current.weight = Math.min(1, Math.max(0, pitch / (Math.PI / 2)))
      aimForwardRef.current.weight = 1 - aimDownRef.current.weight
      aimUpRef.current.weight = 0
    }
  })
  // ...
}
  1. Layer the three aim clips (mask excludes the spine)

We play all three clips together and only change their weights; the mask excludes the spine to avoid conflicts with our manual spine rotation.

return (
  <RunTimeline>
    <Parallel type="all">
      <CharacterAnimationLayer name="aim">
        <CharacterAnimationAction
          boneMap={boneMap}
          mask={upperBodyWithoutSpine}
          url="aim-up.glb"
          crossFade={false}
          ref={aimUpRef}
        />
        <CharacterAnimationAction
          boneMap={boneMap}
          mask={upperBodyWithoutSpine}
          url="aim-forward.glb"
          ref={aimForwardRef}
        />
        <CharacterAnimationAction
          boneMap={boneMap}
          mask={upperBodyWithoutSpine}
          url="aim-down.glb"
          crossFade={false}
          ref={aimDownRef}
        />
      </CharacterAnimationLayer>
    </Parallel>
  </RunTimeline>
)

Step 10 — Additive upper-body: idle, shoot, reload, audio, muzzle flash

Create src/upper-body-additive-animation.tsx. Add these parts:

  1. Attach muzzle flash and audio under the right hand

Audio and flash sit where the muzzle is, so sounds and visuals feel spatially correct. Notice, that the sound only plays when we execute .play() on the attached ref.

<CharacterModelBone bone="rightHand">
  <group position={[0.3, 0, -0.1]}>
    <PositionalAudio ref={reloadAudioRef} loop={false} url="pistol-reload-sound.mp3" />
    <PositionalAudio ref={muzzleFlashAudioRef} loop={false} url="pistol-shoot-sound.mp3" />
    <Billboard scale={0.4}>
      <mesh visible={false} ref={muzzleFlashVisualRef}>
        <planeGeometry />
        <meshBasicMaterial color="#ffcc88" transparent opacity={0.7} map={muzzleflashTexture} />
      </mesh>
    </Billboard>
  </group>
</CharacterModelBone>
  1. Timeline for idle → reload/shoot transitions (additive layer)

An additive layer lets us overlay weapon actions on top of locomotion/aim; timeline transitions keep behavior deterministic and easy to expand.

<RunTimeline>
  <CharacterAnimationLayer name="upper-body">
    <Graph enterState="idle">
      <GrapthState
        name="idle"
        transitionTo={{
          reload: { whenPromise: () => ReloadAction.waitFor() },
          shoot: {
            whenPromise: async () => {
              await ShootAction.waitFor()
              if (useAmmo.getState().ammo === 0) await new Promise(() => {})
            },
          },
        }}
      >
        <AdditiveCharacterAnimationAction
          referenceClip={{ url: 'aim-forward.glb' }}
          url="pistol-idle.glb"
          boneMap={boneMap}
          mask={upperBodyWithoutSpine}
        />
      </GrapthState>
      <GrapthState name="reload" transitionTo={{ finally: 'idle' }}>
        <AdditiveCharacterAnimationAction
          referenceClip={{ url: 'aim-forward.glb' }}
          boneMap={boneMap}
          loop={LoopOnce}
          init={() => {
            reloadAudioRef.current?.play(0.3)
            useAmmo.setState({ ammo: 12 })
          }}
          mask={upperBodyWithoutSpine}
          scaleTime={0.5}
          url="pistol-reload.glb"
        />
      </GrapthState>
      <GrapthState name="shoot" transitionTo={{ finally: 'idle' }}>
        <Parallel type="all">
          <Action
            update={(state) => {
              const jitter = 0.01
              state.camera.rotation.set(
                state.camera.rotation.x + (Math.random() - 0.5) * jitter,
                state.camera.rotation.y + (Math.random() - 0.5) * jitter,
                0,
              )
            }}
            until={() => timePassed(0.11, 'seconds')}
          />
          <Action
            init={() => {
              useAmmo.setState({ ammo: useAmmo.getState().ammo - 1 })
              muzzleFlashAudioRef.current?.stop()
              muzzleFlashAudioRef.current?.play()
              if (muzzleFlashVisualRef.current) {
                muzzleFlashVisualRef.current.visible = true
                return () => (muzzleFlashVisualRef.current!.visible = false)
              }
            }}
            until={() => timePassed(0.07, 'seconds')}
          />
          <AdditiveCharacterAnimationAction
            referenceClip={{ url: 'aim-forward.glb' }}
            boneMap={boneMap}
            loop={LoopOnce}
            mask={upperBodyWithoutSpine}
            fadeDuration={0}
            scaleTime={0.5}
            url="pistol-shoot.glb"
          />
        </Parallel>
      </GrapthState>
    </Graph>
  </CharacterAnimationLayer>
</RunTimeline>

The brief “jitter” recoil in the shoot state nudges the camera by a tiny random amount only while the state is active. Because the Action has an until={() => timePassed(0.11,'seconds')}, the shake starts exactly when the state begins and stops automatically, letting the regular camera behavior bring the view back smoothly (we keep roll at 0 to avoid unwanted tilt).

Step 11 — HUD and crosshair

The HUD is plain React DOM absolutely positioned over the canvas. It reads the player profile and ammo, shows a health bar, and renders a minimal crosshair.

Highlights from src/hud.tsx:

const { name } = useViverseProfile() ?? { name: 'Anonymous', activeAvatar: null }
const ammo = useAmmo((s) => s.ammo)
// ... name at top-left, health bar bottom-left, ammo bottom-right ...
// ... a simple crosshair (centered) composed of small divs ...

Create src/hud.tsx and add the full component. It overlays the canvas (no pointer events on the crosshair) and uses the same system font stack as the example.

export function HUD() {
  const [health, setHealth] = useState(50)
  const ammo = useAmmo((s) => s.ammo)

  const { name } = useViverseProfile() ?? { name: 'Anonymous', activeAvatar: null }

  const percent = Math.max(0, Math.min(100, health))

  return (
    <>
      <div
        style={{
          position: 'absolute',
          top: 16,
          left: 16,
          color: '#fff',
          zIndex: 100000,
          fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, sans-serif',
          textShadow: '0 1px 2px rgba(0,0,0,0.4)',
        }}
      >
        <div style={{ fontWeight: 800, fontSize: 14, letterSpacing: 2 }}>{name}</div>
      </div>

      <div
        style={{
          position: 'absolute',
          bottom: 28,
          left: 28,
          zIndex: 100000,
          display: 'flex',
          alignItems: 'center',
          gap: 12,
          background: 'rgba(0,0,0,0.2)',
          padding: '8px 12px',
          color: '#fff',
          fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, sans-serif',
        }}
      >
        <div style={{ fontWeight: 800, fontSize: 22, lineHeight: 1, transform: 'translate(0, -2px)' }}>+</div>
        <div
          style={{
            width: 260,
            height: 20,
            background: 'rgba(255,255,255,0.3)',
            overflow: 'hidden',
          }}
        >
          <div
            style={{
              width: `${percent}%`,
              height: '100%',
              background: 'linear-gradient(90deg,rgb(31, 224, 102), #2dbb5f)',
            }}
          />
        </div>
        <div style={{ fontWeight: 800, fontSize: 18, minWidth: 36, textAlign: 'right' }}>{Math.round(health)}</div>
      </div>

      <div
        style={{
          zIndex: 100000,
          position: 'absolute',
          bottom: 28,
          right: 28,
          color: '#fff',
          textAlign: 'right',
          fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, sans-serif',
          textShadow: '0 1px 2px rgba(0,0,0,0.4)',
        }}
      >
        <div style={{ fontSize: 13, opacity: 0.85, marginBottom: 4, fontWeight: 700 }}>AMMO</div>
        <div style={{ fontWeight: 800, fontSize: 38 }}>
          {ammo}
          <span style={{ fontSize: 11, opacity: 0.7, fontWeight: 'normal' }}>/ 12</span>
        </div>
      </div>

      {/* Crosshair */}
      <div
        style={{
          zIndex: 100000,
          position: 'absolute',
          top: '50%',
          left: '50%',
          transform: 'translate(-50%, -50%)',
          pointerEvents: 'none',
        }}
      >
        {/* center dot */}
        <div
          style={{
            position: 'absolute',
            width: 2,
            height: 2,
            borderRadius: 1,
            background: 'rgba(255,255,255,0.55)',
            boxShadow: '0 0 0 1px rgba(0,0,0,0.25)',
            transform: 'translate(-1px, -1px)',
          }}
        />
        {/* top line */}
        <div
          style={{
            position: 'absolute',
            left: -1,
            top: -22,
            width: 2,
            height: 8,
            borderRadius: 1,
            background: 'rgba(255,255,255,0.55)',
            boxShadow: '0 0 0 1px rgba(0,0,0,0.15)',
          }}
        />
        {/* bottom line */}
        <div
          style={{
            position: 'absolute',
            left: -1,
            top: 14,
            width: 2,
            height: 8,
            borderRadius: 1,
            background: 'rgba(255,255,255,0.55)',
            boxShadow: '0 0 0 1px rgba(0,0,0,0.15)',
          }}
        />
        {/* left line */}
        <div
          style={{
            position: 'absolute',
            top: -1,
            left: -22,
            width: 8,
            height: 2,
            borderRadius: 1,
            background: 'rgba(255,255,255,0.55)',
            boxShadow: '0 0 0 1px rgba(0,0,0,0.15)',
          }}
        />
        {/* right line */}
        <div
          style={{
            position: 'absolute',
            top: -1,
            left: 14,
            width: 8,
            height: 2,
            borderRadius: 1,
            background: 'rgba(255,255,255,0.55)',
            boxShadow: '0 0 0 1px rgba(0,0,0,0.15)',
          }}
        />
      </div>
    </>
  )
}

At this point your project should behave exactly like examples/fortnite. If anything does not work, compare your project with the files in examples/fortnite.