Build a Wrestling Game with ThreeJs and React in a website

Building a 3D game in a website from scratch can be intimidating but it's not, here will be learning how to create a 3D game using ThreeJs and ReactJs

What is ThreeJs ?

Three.js is a cross-browser JavaScript library and application programming interface (API) used to create and display animated 3D computer graphics in a website.

Let's start with the coding part.

  • First create a react project by entering the command npx create-react-app . .

  • It will create a new folder with a basic react template like this

  • Now install the necessary dependencies by entering: npm install @react-three/cannon @react-three/drei @react-three/fiber three .

  • Now let's dive into the coding part

  • So will go with creating a components folder inside src and than create a file for our ground i.e. Ground.js, here we will be adding physics at the same time using cannonJs so that we don't have to come back again and again.

  •     import React from 'react'
        // to add Physics i.e. useBox since our ground will be in a box shape
        import { useBox } from '@react-three/cannon'
        // to add texture to the ground
        import { useLoader } from '@react-three/fiber'
        import { NearestFilter, RepeatWrapping } from 'three'
        import { TextureLoader } from 'three/src/loaders/TextureLoader'
    
        const Ground = () => {
        // have added planetexture.jpg in the public folder and here it is adding texture to the ground
          const PlaneG = useLoader(TextureLoader, 'planetexture.jpg')
          PlaneG.wrapS = RepeatWrapping
          PlaneG.wrapT = RepeatWrapping
          PlaneG.repeat.set(20,20)
        // Here we are adding Physics to our ground, change the args as per the size that you are giving to your map
            const [ref] = useBox(() => ({
                rotation: [-Math.PI / 2, 0, 0], position: [0, 0, 0], mass: 10, type: "Static", args: [15, 15, 2, 1]
            }))
    
          return (
               {/* here using the ref of physics that we created above */}
            <mesh ref={ref}>
               {/* creating a box geometry where args is the size we can say */}
                <boxBufferGeometry attach='geometry' args={[15, 15, 2, 1]} /> 
                {/* here using the texture that we added above */}
                <meshStandardMaterial map={PlaneG} attach='material'/>
            </mesh>
          )
        }
    
        export default Ground
    

    Now we have our Ground ready!

  • Now that we have the ground let's import it in our App.js file so that we can see it.

  • Remove the unnecessary code from the App.js file, it should look like this -

  •   import './App.css';
    
      function App() {
        return (
          <div className="App">
    
          </div>
        );
      }
    
      export default App;
    

    Now import Ground from the components folder using

import Ground from './components/ground';

now before adding the Ground component you should first use a <Canvas></Canvas> tag and give the div a 100vh height and 100% width + you can add OrbitControls to drag and play with the ground a bit like -

import './App.css';
import { Canvas } from '@react-three/fiber';
import Ground from './components/ground';
import { OrbitControls } from "@react-three/drei"

function App() {
  return (
    <div style={{height: "100vh", width: "100%"}}>
      <Canvas>
          <OrbitControls enableZoom={false} maxPolarAngle={Math.PI / 2.3}/>
        <Ground />
      </Canvas>
    </div>
  );
}

export default App;

now you should be able to see a ground on your local host

now as we have a ground let's create some keyboard inputs, create a folder named hooks in your root folder and than create a file named keyboard.js

now let's map all the keys inside it

function actionByKey(key) {
    const keyActionMap = {
        KeyW: 'stepForward',
        KeyS: 'stepBackward',
        KeyA: 'stepLeft',
        KeyD: 'stepRight',
        Space: 'jump',
        ArrowUp: 'stepForward_2',
        ArrowDown: 'stepBackward_2',
        ArrowLeft: 'stepLeft_2',
        ArrowRight: 'stepRight_2'
    }
    return keyActionMap[key]
}

now let's define all the keys as false and make it true if there is a keydown

export const useKeyboard = () => {
    const [actions, setActions] = useState({
        stepForward: false,
        stepBackward: false,
        stepLeft: false,
        stepRight: false,
        stepForward_2: false,
        stepBackward_2: false,
        stepRight_2: false,
        stepLeft_2: false 
    });

    const handleKeydown = useCallback((e) => {
        const action = actionByKey(e.code)
        if (action) {
            setActions((prev) => {
                return ({
                    ...prev,
                    [action]: true
                })
            })
        }
    }, [])


    const handleKeyup = useCallback((e) => {
        // console.log(e)
        const action = actionByKey(e.code)
        if(action) {
            setActions((prev) => {
                return ({
                    ...prev,
                    [action]: false
                })
            })
        }
    }, [])

    useEffect(() => {
        document.addEventListener('keydown', handleKeydown);
        document.addEventListener('keyup', handleKeyup)
        return() => {
            document.removeEventListener('keyup', handleKeyup)
            document.removeEventListener('keydown', handleKeydown)
        }
    }, [handleKeydown, handleKeyup])

  return actions;
}

now we can use the keyboard inputs anywhere, you can see that here since we are making a local multiplayer game so we have 2 inputs defined for all kind of actions.

now let's create a player(will do the same for player2, will just have to change the keyboard inputs there) and use these keyboard inputs there to make them move.

now we have 2 options, 1 is to import a player that you can do easily using GLTFLoader but here we are just gonna use some basic shapes for that i.e. boxes.

now in order to add the players we are just gonna use <boxBufferGeometry/> with <meshBasicMaterial /> to add texture or color you can choose it as you want.

After that will import the keyboard inputs using

import { useKeyboard } from "../hooks/Keyboard";

now will just pass it inside using-

const { stepBackward, stepForward, stepRight, stepLeft, jump } = useKeyboard()

now in order to ask for names we are just gonna use prompt and than use it in the game, gonna do the same for both the players and just add a trim to check if it's not empty like -

do{
    var name2 = prompt("What should I call you player-2 ?").trim()
} while (name2 !== null && name2 === "")

and then we can just use it using {name2}.

now will add physics to it using CannonJs a physics engine, since currently we are using a box so CannonJs already have some shapes defined that we can use or else we would have to define the shape and all using the args but we are lucky for now so let's just use useBox by importing it from cannonJs and after that just create a ref for it which we can pass on to the player.

// make sure you have imported useBox before using it.
const [ref, api] = useBox(() => ({
        mass: 1,
        type: "Dynamic",
        position: [0, 4, 3]
}))

now just enter it as a ref in your mesh where you have added the boxBuffer geometry like -

 <mesh ref={ref}>
                <Html>
                    <p style={{ padding: "5px", color: "hotpink" }}>{name1}</p>
                </Html>
                <boxBufferGeometry attach="geometry" />
                <meshStandardMaterial map={player1} color="#202020" attach='material' />
            </mesh>

in the above code you can't directly use any of the html tags so for that first import Html from @react-three/drei and than use it inside the <Html></Html> tag

now we need to see the position of the players as well since we are using it to see which player is where and if any player died or not.

const vel = useRef([0, 0, 0])
    useEffect(() => {
        api.velocity.subscribe((v) => vel.current = v)
    }, [api.velocity])

    const pos = useRef([0, 0, 0])
    useEffect(() => {
        api.position.subscribe((p) => pos.current = p)
    }, [api.position])

now we can see the position of players using the pos.current[index] but we will just round it off later on, first let's complete the keyboard inputs to make the players move.

now will just define the directions like what to do if 2 keys are pressed at the same time.

    useFrame(() => {
        const direction = new Vector3()
        const frontVector = new Vector3(
            0,
            0,
            (stepBackward ? 1 : 0) - (stepForward ? 1 : 0)
        )
        const sideVector = new Vector3(
            (stepLeft ? 1 : 0) - (stepRight ? 1 : 0),
            0,
            0,
        )

        direction
            .subVectors(frontVector, sideVector)
            .normalize()
            .multiplyScalar(speed)
            .applyEuler(camera.rotation)

        api.velocity.set(direction.x, vel.current[1], direction.z)
    })

now you should be able to make the player move, now let's check their position and add the winning and losing logic which is quiet simple since we are just gonna check the position of the players and if one of the player's position is less than 0 than that's possible only if they fall so will just make the other player win.

const itemPos_2 = [Math.round(pos.current[0]), Math.round(pos.current[1]), Math.round(pos.current[2])];
    console.log(itemPos_2)
    if (
        itemPos_2[1] < 0
    ) {
        console.log("Red lose")
        alert(`${name1} died, Enter to restart`)
        window.location.reload()

at the end the Player.js file should look like this -

import React, { useEffect, useRef } from "react";
import { useBox } from "@react-three/cannon";
import { useFrame, useThree, useLoader } from "@react-three/fiber";
import { Vector3 } from "three";
import { RepeatWrapping } from "three"
import { useKeyboard } from "../hooks/Keyboard";
import { TextureLoader } from "three/src/loaders/TextureLoader"
import { Html, Text } from "@react-three/drei";


const speed = 4

do{
    var name1 = prompt("What should I call you player-1 ?").trim()
} while (name1 !== null && name1 === "")

const Player = () => {


    const player1 = useLoader(TextureLoader, 'texture.jpg')
    player1.wrapS = RepeatWrapping
    player1.wrapT = RepeatWrapping
    player1.repeat.set(1, 1)

    const [ref, api] = useBox(() => ({
        mass: 1,
        type: "Dynamic",
        position: [0, 4, 3]
    }))
    const vel = useRef([0, 0, 0])
    const { stepBackward, stepForward, stepRight, stepLeft, jump } = useKeyboard()
    useEffect(() => {
        api.velocity.subscribe((v) => vel.current = v)
    }, [api.velocity])

    const pos = useRef([0, 0, 0])
    useEffect(() => {
        api.position.subscribe((p) => pos.current = p)
    }, [api.position])

    useFrame(() => {
        const direction = new Vector3()
        const frontVector = new Vector3(
            0,
            0,
            (stepBackward ? 1 : 0) - (stepForward ? 1 : 0)
        )
        const sideVector = new Vector3(
            (stepLeft ? 1 : 0) - (stepRight ? 1 : 0),
            0,
            0,
        )

        direction
            .subVectors(frontVector, sideVector)
            .normalize()
            .multiplyScalar(speed)
            .applyEuler(camera.rotation)

        api.velocity.set(direction.x, vel.current[1], direction.z)
    })
    const itemPos_2 = [Math.round(pos.current[0]), Math.round(pos.current[1]), Math.round(pos.current[2])];
    console.log(itemPos_2)
    if (
        // itemPos_2[2] > 10 || itemPos_2[0] > 10 || itemPos_2[2] < -10 || itemPos_2[0] < -10
        itemPos_2[1] < 0
    ) {
        console.log("Red lose")
        alert(`${name1} died, Enter to restart`)
        window.location.reload()
    }

    const { camera } = useThree()

    return (
        <>
            <mesh ref={ref}>
                <Html>
                    <p style={{ padding: "5px", color: "hotpink" }}>{name1}</p>
                </Html>
                <boxBufferGeometry attach="geometry" />
                <meshStandardMaterial map={player1} color="#202020" attach='material' />
            </mesh>
        </>
    )
}

export default Player;

now we have the 1st player but not the 2nd but it is exactly same, we just have to change the control inputs for it so just gonna paste it's code below.

import { useBox } from "@react-three/cannon";
import React, { useRef, useEffect, useState } from "react";
import { useKeyboard } from "../hooks/Keyboard";
import { useFrame, useThree, useLoader } from "@react-three/fiber";
import { Vector3 } from "three";
import { TextureLoader } from "three/src/loaders/TextureLoader"
import { NearestFilter, RepeatWrapping } from "three";
import { Html, Text } from "@react-three/drei"


const speed_2 = 4

do{
    var name2 = prompt("What should I call you player-2 ?").trim()
} while (name2 !== null && name2 === "")


const Hurdle = () => {
    const Player2 = useLoader(TextureLoader, 'texture1.jpg')
    Player2.wrapS = RepeatWrapping
    Player2.wrapT = RepeatWrapping
    Player2.repeat.set(1, 1)

    const { id } = socket

    const [ref, api_2] = useBox(() => ({
        mass: 1,
        type: "Dynamic",
        position: [4, 8, 3]
    }))

    const vel_2 = useRef([0, 0, 0])
    const { stepBackward_2, stepForward_2, stepRight_2, stepLeft_2 } = useKeyboard()
    useEffect(() => {
        api_2.velocity.subscribe((v) => vel_2.current = v)
        console.log(pos_2)
    }, [api_2.velocity])


    const pos_2 = useRef([0, 0, 0])
    useEffect(() => {
        api_2.position.subscribe((p) => pos_2.current = p)
    }, [api_2.position])

    useFrame(() => {
        const direction = new Vector3()
        const frontVector = new Vector3(
            0,
            0,
            (stepBackward_2 ? 1 : 0) - (stepForward_2 ? 1 : 0)
        )
        const sideVector = new Vector3(
            (stepLeft_2 ? 1 : 0) - (stepRight_2 ? 1 : 0),
            0,
            0,
        )

        direction
            .subVectors(frontVector, sideVector)
            .normalize()
            .multiplyScalar(speed_2)
            .applyEuler(camera.rotation)

        api_2.velocity.set(direction.x, vel_2.current[1], direction.z)
    })

    const posArray = pos_2.current

    socket.emit("player", {
        id,
        position: posArray
    })
    const { camera } = useThree()
    const itemPos = [Math.round(pos_2.current[0]), Math.round(pos_2.current[1]), Math.round(pos_2.current[2])];
    console.log(itemPos)
    if (
        itemPos[1] < 0
    ) {
        console.log("Blue lose")
        alert(`${name2} died, Enter to restart`)
        window.location.reload()

    }

    return (
        <>
            <mesh ref={ref}>
                <Html>
                    <p style={{ padding: "5px" }}>{name2}</p>
                </Html>
                <boxBufferGeometry attach="geometry" />
                <meshStandardMaterial map={Player2} color="hotpink" attach='material' />
            </mesh>
        </>
    )
}

export default Hurdle;

now you just have to import it in the App.js, have even added environment there using environment from @react-three/drei

import { Canvas } from '@react-three/fiber';
import { Environment, OrbitControls } from "@react-three/drei"
import Ground from './components/ground';
import Player from './components/Player';
import './App.css';
import { Physics } from '@react-three/cannon';
import Hurdle from './components/Hurdle';
import { useEnvironment } from '@react-three/drei';
import { Suspense } from 'react';
import { Html } from '@react-three/drei';


function App() {

  const envMap = useEnvironment({ path: "/environment" })

  return (
    <div className="App">
      <Canvas camera={{ position: [0, 5, 12] }}>
        <Suspense fallback={<Html><h1>Loading...</h1></Html>}>
          <OrbitControls enableZoom={false} maxPolarAngle={Math.PI / 2.3}/>
          <ambientLight />
          <Physics>
            <Hurdle />
            <Player />
            <Ground />
          </Physics>
          <Environment map={envMap} background />
        </Suspense>
      </Canvas>
    </div>
  );
}

export default App;

for the environment make sure you have the images in the environment folder inside the public folder and you should be able to add the environment in your game.

Now you should be able to see your game and just host it anywhere you want to !

Incase you're facing any error you can always dm me on twitter or just take reference from the github repository https://github.com/KlausMikhaelson/wrestle-with-shapes

Will be making it online multiplayer soon so stay tuned for it!