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) underpublic/. - 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,directionalLightwith 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
BvhPhysicsBodybuilds a BVH acceleration structure, necessary for performing collision detections. - We set
receiveShadow = trueon the ground mesh and re-create the material to ensure proper PBR/shadowing with the embedded base color map. - Keep your
map.glbinpublic/so it’s served statically by Vite. - If you wonder why we load via
src="map.glb"instead ofimport, 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
boneMaplets 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.
- 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)
}
})
// ...
}
- Timeline scaffold and movement state (add this inside the return)
We use a small timeline graph:
RunTimelineevaluates the timeline every frame.CharacterAnimationLayergroups animation actions for a specific body region.Graphdeclares states and transitions.GrapthStateis 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>
- 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:
- 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])
// ...
}
- 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:
- 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
}
})
// ...
}
- 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:
- 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>
- 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.