Build a Multiplayer Pirate Card Game with React Three Fiber and Playroom
Learn how to build a multiplayer pirate card game where pirates compete to grab gems while punching each other. You’ll use React Three Fiber for 3D rendering, Playroom Kit for multiplayer streaming, and create a streaming session with mobile controllers.
Getting Started
This tutorial shows you how to build a multiplayer pirate card game inspired by classic party games. Players take turns playing cards to either grab gems from the treasure, punch other players to steal their gems, or shield themselves from attacks. The game uses Playroom Kit’s streaming mode using insertCoin with streamMode: true to display the main game board on one screen while players join and play using their smartphones as controllers.
The game features real-time multiplayer synchronization using useMultiplayerState, card-based gameplay mechanics, physics-based character animations, and a complete game loop with rounds and winner detection. All game state is managed through Playroom Kit, which handles the multiplayer networking and streaming without requiring a custom backend server.
Vibe Coding System Prompt
Vibe-code friendly
If you are using an AI coding tool, copy this prompt:
You are a senior software engineer at Little Umbrella specializing in building real-time multiplayer applications using Playroom Kit and React.Your task is to help me build a "Multiplayer Card Game".The application should be created using React as a frontend framework and Playroom Kit as a multiplayer framework.Do not assume the existence of any Playroom Kit APIs or hooks. Only use APIs documented in the official Playroom Kit documentation.I already have a React project set up.I will provide step-by-step instructions. After each step, clearly explain what was implemented and ask if I want customizations before proceeding.
You can adjust the prompt according to whether or not you have an existing application.
Guide
If you haven’t cloned the starter repository yet, clone it before moving to the first step. The starter code contains all the 3D assets, audio files, and basic React setup you’ll need.
If you’re vibe-coding, start by copying the Vibe Coding System prompt. Then copy each step one by one into your coding assistant by clicking “Copy Prompt”.
Step 1: Initialize Playroom Kit in main.jsx
Initialize Playroom Kit to handle multiplayer streaming using insertCoin with streamMode: true. This enables the game to run in streaming mode where the main screen shows the game board while players join using their smartphones as controllers.
import { insertCoin } from "playroomkit";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { GameEngineProvider } from "./hooks/useGameEngine";
import "./index.css";
insertCoin({
streamMode: true,
}).then(() => {
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<GameEngineProvider>
<App />
</GameEngineProvider>
</React.StrictMode>
);
});Step 2: Set Up the Main App with Canvas and Motion Config
Set up the main App component with React Three Fiber Canvas, motion configuration for animations, and conditional rendering using isStreamScreen to determine if the device is the main streaming screen or a mobile controller. Use isHost for debug controls visibility.
import { Canvas } from "@react-three/fiber";
import { MotionConfig } from "framer-motion";
import { Leva } from "leva";
import { isHost, isStreamScreen } from "playroomkit";
import { Experience } from "./components/Experience";
import { UI } from "./components/UI";
const DEBUG = false;
function App() {
return (
<>
<Leva hidden={!DEBUG || !isHost()} />
<Canvas
shadows
camera={{
position: isStreamScreen() ? [14, 10, -14] : [0, 4, 12],
fov: 30,
}}
>
<color attach="background" args={["#ececec"]} />
<MotionConfig
transition={{
type: "spring",
mass: 5,
stiffness: 500,
damping: 100,
restDelta: 0.0001,
}}
>
<Experience />
</MotionConfig>
</Canvas>
<UI />
</>
);
}
export default App;Step 3: Create the Experience Component
Create the Experience component that handles rendering different views based on whether the device is the main streaming screen or a player’s mobile controller using isStreamScreen.
import { Environment, OrbitControls } from "@react-three/drei";
import { isStreamScreen } from "playroomkit";
import { degToRad } from "three/src/math/MathUtils";
import { Gameboard } from "./Gameboard";
import { MobileController } from "./MobileController";
export const Experience = () => {
return (
<>
{isStreamScreen() && <OrbitControls maxPolarAngle={degToRad(80)} />}
{isStreamScreen() ? <Gameboard /> : <MobileController />}
<Environment preset="dawn" background blur={2} />
</>
);
};Step 4: Create the useGameEngine Hook
Create the useGameEngine hook that manages all game state using useMultiplayerState, usePlayersList, getState, isHost, and onPlayerJoin.
import { useControls } from "leva";
import {
getState,
isHost,
onPlayerJoin,
useMultiplayerState,
usePlayersList,
} from "playroomkit";
import React, { useEffect, useRef } from "react";
import { randInt } from "three/src/math/MathUtils";
const GameEngineContext = React.createContext();
const TIME_PHASE_CARDS = 10;
const TIME_PHASE_PLAYER_CHOICE = 10;
const TIME_PHASE_PLAYER_ACTION = 3;
export const NB_ROUNDS = 3;
const NB_GEMS = 3;
const CARDS_PER_PLAYER = 4;
export const GameEngineProvider = ({ children }) => {
const [timer, setTimer] = useMultiplayerState("timer", 0);
const [round, setRound] = useMultiplayerState("round", 1);
const [phase, setPhase] = useMultiplayerState("phase", "lobby");
const [playerTurn, setPlayerTurn] = useMultiplayerState("playerTurn", 0);
const [playerStart, setPlayerStart] = useMultiplayerState("playerStart", 0);
const [deck, setDeck] = useMultiplayerState("deck", []);
const [gems, setGems] = useMultiplayerState("gems", NB_GEMS);
const [actionSuccess, setActionSuccess] = useMultiplayerState(
"actionSuccess",
true
);
const players = usePlayersList(true);
players.sort((a, b) => a.id.localeCompare(b.id));
const gameState = {
timer,
round,
phase,
playerTurn,
playerStart,
players,
gems,
deck,
actionSuccess,
};
const distributeCards = (nbCards) => {
const newDeck = [...getState("deck")];
players.forEach((player) => {
const cards = player.getState("cards") || [];
for (let i = 0; i < nbCards; i++) {
const randomIndex = randInt(0, newDeck.length - 1);
cards.push(newDeck[randomIndex]);
newDeck.splice(randomIndex, 1);
}
player.setState("cards", cards, true);
player.setState("selectedCard", 0, true);
player.setState("playerTarget", -1, true);
});
setDeck(newDeck, true);
};
const startGame = () => {
if (isHost()) {
console.log("Start game");
setTimer(TIME_PHASE_CARDS, true);
const randomPlayer = randInt(0, players.length - 1);
setPlayerStart(randomPlayer, true);
setPlayerTurn(randomPlayer, true);
setRound(1, true);
setDeck(
[
...new Array(16).fill(0).map(() => "punch"),
...new Array(24).fill(0).map(() => "grab"),
...new Array(8).fill(0).map(() => "shield"),
],
true
);
setGems(NB_GEMS, true);
players.forEach((player) => {
console.log("Setting up player", player.id);
player.setState("cards", [], true);
player.setState("gems", 0, true);
player.setState("shield", false, true);
player.setState("winner", false, true);
});
distributeCards(CARDS_PER_PLAYER);
setPhase("cards", true);
}
};
useEffect(() => {
startGame();
onPlayerJoin(startGame);
}, []);
const performPlayerAction = () => {
const player = players[getState("playerTurn")];
console.log("Perform player action", player.id);
const selectedCard = player.getState("selectedCard");
const cards = player.getState("cards");
const card = cards[selectedCard];
let success = true;
if (card !== "shield") {
player.setState("shield", false, true);
}
switch (card) {
case "punch":
let target = players[player.getState("playerTarget")];
if (!target) {
let targetIndex = (getState("playerTurn") + 1) % players.length;
player.setState("playerTarget", targetIndex, true);
target = players[targetIndex];
}
console.log("Punch target", target.id);
if (target.getState("shield")) {
console.log("Target is shielded");
success = false;
break;
}
if (target.getState("gems") > 0) {
target.setState("gems", target.getState("gems") - 1, true);
setGems(getState("gems") + 1, true);
console.log("Target has gems");
}
break;
case "grab":
if (getState("gems") > 0) {
player.setState("gems", player.getState("gems") + 1, true);
setGems(getState("gems") - 1, true);
console.log("Grabbed gem");
} else {
console.log("No gems available");
success = false;
}
break;
case "shield":
console.log("Shield");
player.setState("shield", true, true);
break;
default:
break;
}
setActionSuccess(success, true);
};
const removePlayerCard = () => {
const player = players[getState("playerTurn")];
const cards = player.getState("cards");
const selectedCard = player.getState("selectedCard");
cards.splice(selectedCard, 1);
player.setState("cards", cards, true);
};
const getCard = () => {
const player = players[getState("playerTurn")];
if (!player) {
return "";
}
const cards = player.getState("cards");
if (!cards) {
return "";
}
const selectedCard = player.getState("selectedCard");
return cards[selectedCard];
};
const phaseEnd = () => {
let newTime = 0;
switch (getState("phase")) {
case "cards":
if (getCard() === "punch") {
setPhase("playerChoice", true);
newTime = TIME_PHASE_PLAYER_CHOICE;
} else {
performPlayerAction();
setPhase("playerAction", true);
newTime = TIME_PHASE_PLAYER_ACTION;
}
break;
case "playerChoice":
performPlayerAction();
setPhase("playerAction", true);
newTime = TIME_PHASE_PLAYER_ACTION;
break;
case "playerAction":
removePlayerCard();
const newPlayerTurn = (getState("playerTurn") + 1) % players.length;
if (newPlayerTurn === getState("playerStart")) {
if (getState("round") === NB_ROUNDS) {
console.log("End of game");
let maxGems = 0;
players.forEach((player) => {
if (player.getState("gems") > maxGems) {
maxGems = player.getState("gems");
}
});
players.forEach((player) => {
player.setState(
"winner",
player.getState("gems") === maxGems,
true
);
player.setState("cards", [], true);
});
setPhase("end", true);
} else {
console.log("Next round");
const newPlayerStart =
(getState("playerStart") + 1) % players.length;
setPlayerStart(newPlayerStart, true);
setPlayerTurn(newPlayerStart, true);
setRound(getState("round") + 1, true);
distributeCards(1);
setPhase("cards", true);
newTime = TIME_PHASE_CARDS;
}
} else {
console.log("Next player");
setPlayerTurn(newPlayerTurn, true);
if (getCard() === "punch") {
setPhase("playerChoice", true);
newTime = TIME_PHASE_PLAYER_CHOICE;
} else {
performPlayerAction();
setPhase("playerAction", true);
newTime = TIME_PHASE_PLAYER_ACTION;
}
}
break;
default:
break;
}
setTimer(newTime, true);
};
const { paused } = useControls({
paused: false,
});
const timerInterval = useRef();
const runTimer = () => {
timerInterval.current = setInterval(() => {
if (!isHost()) return;
if (paused) return;
let newTime = getState("timer") - 1;
console.log("Timer", newTime);
if (newTime <= 0) {
phaseEnd();
} else {
setTimer(newTime, true);
}
}, 1000);
};
const clearTimer = () => {
clearInterval(timerInterval.current);
};
useEffect(() => {
runTimer();
return clearTimer;
}, [phase, paused]);
return (
<GameEngineContext.Provider
value={{
...gameState,
startGame,
getCard,
}}
>
{children}
</GameEngineContext.Provider>
);
};
export const useGameEngine = () => {
const context = React.useContext(GameEngineContext);
if (context === undefined) {
throw new Error("useGameEngine must be used within a GameEngineProvider");
}
return context;
};Step 5: Create the UI Component
Create the UI component that displays the game HUD, round counter, timer, phase labels, winner announcement, and audio controls using isHost, isStreamScreen, and myPlayer.
import { isHost, isStreamScreen, myPlayer } from "playroomkit";
import { useEffect, useState } from "react";
import { NB_ROUNDS, useGameEngine } from "../hooks/useGameEngine";
const audios = {
background: new Audio("/audios/Drunken Sailor - Cooper Cannell.mp3"),
punch: new Audio("/audios/punch.mp3"),
shield: new Audio("/audios/shield.mp3"),
grab: new Audio("/audios/grab.mp3"),
fail: new Audio("/audios/fail.mp3"),
cards: new Audio("/audios/cards.mp3"),
};
export const UI = () => {
const {
phase,
startGame,
timer,
playerTurn,
players,
round,
getCard,
actionSuccess,
} = useGameEngine();
const currentPlayer = players[playerTurn];
const me = myPlayer();
const currentCard = getCard();
const target =
phase === "playerAction" &&
currentCard === "punch" &&
players[currentPlayer.getState("playerTarget")];
let label = "";
switch (phase) {
case "cards":
label = "Select the card you want to play";
break;
case "playerChoice":
label =
currentPlayer.id === me.id
? "Select the player you want to punch"
: `${currentPlayer?.state.profile?.name} is going to punch someone`;
break;
case "playerAction":
switch (currentCard) {
case "punch":
label = actionSuccess
? `${currentPlayer?.state.profile?.name} is punching ${target?.state.profile?.name}`
: `${currentPlayer?.state.profile?.name} failed punching ${target?.state.profile?.name}`;
break;
case "grab":
label = actionSuccess
? `${currentPlayer?.state.profile?.name} is grabbing a gem`
: `No more gems for ${currentPlayer?.state.profile?.name}`;
break;
case "shield":
label = `${currentPlayer?.state.profile?.name} can't be punched until next turn`;
break;
default:
break;
}
break;
case "end":
label = "Game Over";
break;
default:
break;
}
const [audioEnabled, setAudioEnabled] = useState(false);
const toggleAudio = () => {
setAudioEnabled((prev) => !prev);
};
useEffect(() => {
if (audioEnabled) {
audios.background.play();
audios.background.loop = true;
} else {
audios.background.pause();
}
return () => {
audios.background.pause();
};
}, [audioEnabled]);
useEffect(() => {
if (!audioEnabled) {
return;
}
let audioToPlay;
if (phase === "playerAction") {
if (actionSuccess) {
audioToPlay = audios[getCard()];
} else {
audioToPlay = audios.fail;
}
}
if (phase === "cards") {
audioToPlay = audios.cards;
}
if (audioToPlay) {
audioToPlay.currentTime = 0;
audioToPlay.play();
}
}, [phase, actionSuccess, audioEnabled]);
return (
<div className="text-white drop-shadow-xl fixed top-0 left-0 right-0 bottom-0 z-10 flex flex-col pointer-events-none">
<div className="p-4 w-full flex items-center justify-between">
<h2 className="text-2xl font-bold text-center uppercase">
Round {round}/{NB_ROUNDS}
</h2>
<div className=" flex items-center gap-1 w-14">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<h2 className="text-2xl font-bold text-center uppercase">{timer}</h2>
</div>
</div>
<div className="flex-1" />
<div className="p-4 w-full">
<h1 className="text-2xl font-bold text-center">{label}</h1>
{phase === "end" && (
<p className="text-center">
Winner:{" "}
{players
.filter((player) => player.getState("winner"))
.map((player) => player.state.profile.name)
.join(", ")}
!
</p>
)}
{isHost() && phase === "end" && (
<button
onClick={startGame}
className="mt-2 w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded pointer-events-auto"
>
Play again
</button>
)}
</div>
{isStreamScreen() && (
<button
className="fixed bottom-4 left-4 pointer-events-auto"
onClick={toggleAudio}
>
{audioEnabled ? (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.25 9.75 19.5 12m0 0 2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6 4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"
/>
</svg>
)}
</button>
)}
</div>
);
};Step 6: Create the Gameboard Component
Create the Gameboard component that renders the main game scene with the board, deck, gems, and players.
import {
AccumulativeShadows,
Gltf,
RandomizedLight,
useGLTF,
} from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import { motion } from "framer-motion-3d";
import { useMemo } from "react";
import { degToRad } from "three/src/math/MathUtils";
import { useGameEngine } from "../hooks/useGameEngine";
import { Card } from "./Card";
import { Player } from "./Player";
export const Gameboard = () => {
const viewport = useThree((state) => state.viewport);
const scalingRatio = Math.min(1, viewport.width / 12);
const { deck, gems, players, phase, getCard } = useGameEngine();
const shadows = useMemo(
() => (
<AccumulativeShadows
temporal
frames={35}
alphaTest={0.75}
scale={100}
position={[0, 0.01, 0]}
color="#EFBD4E"
>
<RandomizedLight
amount={4}
radius={9}
intensity={0.55}
ambient={0.25}
position={[30, 5, -10]}
/>
<RandomizedLight
amount={4}
radius={5}
intensity={0.25}
ambient={0.55}
position={[-30, 5, -9]}
/>
</AccumulativeShadows>
),
[]
);
return (
<group scale={scalingRatio}>
{/* BG */}
<Gltf
castShadow
src="/models/Gameboard.glb"
scale={0.8}
position-x={-1}
position-z={5}
/>
{shadows}
{/* DECK */}
<group position-x={4} position-z={-2}>
{deck.map((_, index) => (
<motion.group
key={index}
position-y={index * 0.015}
rotation-y={index % 2 ? degToRad(2) : 0}
animate={
phase === "playerAction" && index === deck.length - 1
? "selected"
: ""
}
variants={{
selected: {
x: -2,
y: 1.5,
z: -2,
rotateY: degToRad(120),
scale: 1.5,
},
}}
>
<motion.group
rotation-x={degToRad(90)}
variants={{
selected: {
rotateX: degToRad(-45),
},
}}
>
<Card type={getCard() || undefined} />
</motion.group>
</motion.group>
))}
</group>
{/* TREASURE */}
{[...Array(gems)].map((_, index) => (
<Gltf
key={index}
src="/models/UI_Gem_Blue.gltf"
position-x={index * 0.5}
position-y={0.25}
scale={0.5}
/>
))}
{/* CHARACTERS */}
{players.map((player, index) => (
<group key={player.id}>
<Player index={index} player={player} />
</group>
))}
</group>
);
};
useGLTF.preload("/models/Gameboard.glb");
useGLTF.preload("/models/UI_Gem_Blue.gltf");Step 7: Create the Player Component
Create the Player component that renders a player on the gameboard with animations based on their current action.
import { Center, Gltf } from "@react-three/drei";
import { motion } from "framer-motion-3d";
import { useEffect, useState } from "react";
import { degToRad } from "three/src/math/MathUtils";
import { useGameEngine } from "../hooks/useGameEngine";
import { Character } from "./Character";
import { PlayerName } from "./PlayerName";
export const Player = ({ index, player }) => {
const { phase, playerTurn, players, getCard } = useGameEngine();
const isPlayerTurn = phase === "playerAction" && index === playerTurn;
const currentPlayer = players[playerTurn];
const currentCard = getCard();
const hasShield = player.getState("shield");
const isPlayerPunched =
phase === "playerAction" &&
currentCard === "punch" &&
index === currentPlayer.getState("playerTarget");
const isWinner = player.getState("winner");
const [animation, setAnimation] = useState("Idle");
useEffect(() => {
let cardAnim = "Idle";
if (isPlayerTurn) {
switch (currentCard) {
case "punch":
cardAnim = "Sword";
break;
case "shield":
cardAnim = "Wave";
break;
case "grab":
cardAnim = "Punch";
break;
default:
break;
}
} else {
if (isPlayerPunched) {
cardAnim = "Duck";
}
}
if (isWinner) {
cardAnim = "Wave";
}
setAnimation(cardAnim);
}, [currentCard, playerTurn, phase, isPlayerPunched, isWinner]);
return (
<motion.group
animate={animation}
position-x={1 + index}
position-z={2}
variants={{
Sword: {
z: 0.2,
x: -1,
},
Wave: {
scale: 1.5,
},
Punch: {
x: 0,
z: 0.4,
},
Duck: {
z: -0.4,
x: -1,
rotateY: degToRad(180),
},
}}
>
<PlayerName name={player.state.profile.name} position-y={0.8} />
<Character
scale={0.5}
character={index}
rotation-y={degToRad(180)}
animation={animation}
/>
{hasShield && <Gltf scale={0.5} src="/models/Prop_Barrel.gltf" />}
{/* PLAYER GEMS */}
<Center disableY disableZ>
{[...Array(player.getState("gems") || 0)].map((_, index) => (
<Gltf
key={index}
src="/models/UI_Gem_Blue.gltf"
position-x={index * 0.25}
position-y={0.25}
position-z={0.5}
scale={0.5}
/>
))}
</Center>
</motion.group>
);
};Step 8: Create the Card Component
Create the Card component that renders a 3D card with textures and text descriptions.
import { Text, useFont, useGLTF, useTexture } from "@react-three/drei";
import React from "react";
const CARD_DESCRIPTIONS = {
punch: "Punch another pirate and make it drop a gem",
shield: "Protect yourself from an attack",
grab: "Grab a gem from the treasure. If no gem is left, you get nothing",
};
export function Card({ type = "shield", ...props }) {
const { nodes, materials } = useGLTF("/models/card.glb");
const texture = useTexture(`cards/${type}.jpg`);
return (
<group {...props} dispose={null}>
<mesh castShadow receiveShadow geometry={nodes.Plane.geometry}>
<meshStandardMaterial
{...materials.Front}
map={texture}
color="white"
/>
</mesh>
<mesh
castShadow
receiveShadow
geometry={nodes.Plane_1.geometry}
material={materials.Borders}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Plane_2.geometry}
material={materials.Back}
/>
<Text
font="/fonts/RobotoSlab-Bold.ttf"
fontSize={0.1}
anchorY={"top"}
anchorX={"left"}
position-x={-0.46}
position-y={-0.3}
position-z={0.01}
>
{type.toUpperCase()}
<meshStandardMaterial
color="white"
roughness={materials.Front.roughness}
/>
</Text>
<Text
font="/fonts/RobotoSlab-Regular.ttf"
fontSize={0.06}
maxWidth={0.9}
anchorY={"top"}
anchorX={"left"}
position-x={-0.46}
position-y={-0.44}
position-z={0.01}
lineHeight={1}
>
{CARD_DESCRIPTIONS[type] || ""}
<meshStandardMaterial
color="white"
roughness={materials.Front.roughness}
/>
</Text>
</group>
);
}
useGLTF.preload("/models/card.glb");
useTexture.preload("/cards/punch.jpg");
useTexture.preload("/cards/shield.jpg");
useTexture.preload("/cards/grab.jpg");
useFont.preload("/fonts/RobotoSlab-Bold.ttf");
useFont.preload("/fonts/RobotoSlab-Regular.ttf");Step 9: Create the MobileController Component
Create the MobileController component that renders the mobile controller UI for players with card selection and target picking.
import { Center, ContactShadows, Gltf } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import { motion } from "framer-motion-3d";
import { myPlayer, usePlayersList } from "playroomkit";
import { degToRad } from "three/src/math/MathUtils";
import { useGameEngine } from "../hooks/useGameEngine";
import { Card } from "./Card";
import { Character } from "./Character";
import { PlayerName } from "./PlayerName";
export const MobileController = () => {
const me = myPlayer();
const { players, phase, playerTurn } = useGameEngine();
const myIndex = players.findIndex((player) => player.id === me.id);
const cards = me.getState("cards") || [];
usePlayersList(true);
let playerIdx = 0;
const viewport = useThree((state) => state.viewport);
const scalingRatio = Math.min(1, viewport.width / 3);
return (
<group position-y={-1}>
<ContactShadows opacity={0.12} />
<group scale={scalingRatio}>
<group position-z={3.5} position-x={-0.6}>
<PlayerName
name={me.state.profile.name}
position-y={0.8}
fontSize={0.1}
/>
<Character
character={myIndex}
rotation-y={degToRad(45)}
scale={0.4}
/>
{[...Array(me.getState("gems") || 0)].map((_, index) => (
<Gltf
key={index}
src="/models/UI_Gem_Blue.gltf"
position-x={0.7 + index * 0.25}
position-y={0.25}
scale={0.5}
/>
))}
</group>
{/* CARDS */}
<group position-y={1}>
{cards.map((card, index) => {
let cardAnimState = "";
const selected = index === me.getState("selectedCard");
if (phase === "cards") {
cardAnimState = "cardSelection";
if (selected) {
cardAnimState = "cardSelectionSelected";
}
} else {
if (selected) {
cardAnimState = "selected";
}
}
return (
<motion.group
key={index}
position-x={index * 0.1}
position-y={2 - index * 0.1}
position-z={-index * 0.1}
animate={cardAnimState}
variants={{
selected: {
x: -0.1,
y: 2.1,
z: 0.1,
},
cardSelection: {
x: index % 2 ? 0.6 : -0.6,
y: Math.floor(index / 2) * 1.6,
z: -0.5,
},
cardSelectionSelected: {
x: 0,
y: 0,
z: 2,
rotateX: degToRad(-45),
rotateY: 0,
rotateZ: 0,
scale: 1.1,
},
}}
onClick={() => {
if (phase === "cards") {
me.setState("selectedCard", index, true);
}
}}
>
<Card type={card} />
</motion.group>
);
})}
</group>
{phase === "playerChoice" && players[playerTurn].id === me.id && (
<Center disableY disableZ>
{players.map(
(player, index) =>
player.id !== me.id && (
<motion.group
key={player.id}
position-x={playerIdx++ * 0.8}
position-z={-2}
animate={
index === me.getState("playerTarget") ? "selected" : ""
}
scale={0.4}
variants={{
selected: {
z: 0,
scale: 0.8,
},
}}
>
<mesh
onClick={() => me.setState("playerTarget", index, true)}
position-y={1}
visible={false}
>
<boxGeometry args={[1.2, 2, 0.5]} />
<meshStandardMaterial color="hotpink" />
</mesh>
<PlayerName
name={player.state.profile.name}
fontSize={0.3}
position-y={1.6}
/>
<Character
character={index}
animation={
index === me.getState("playerTarget") ? "No" : "Idle"
}
name={player.state.profile.name}
/>
</motion.group>
)
)}
</Center>
)}
</group>
</group>
);
};Step 10: Create the Character and PlayerName Components
Create the Character and PlayerName components for rendering 3D characters and player name labels.
import { useAnimations, useGLTF } from "@react-three/drei";
import { useEffect, useRef } from "react";
const CHARACTERS = ["Anne", "Captain_Barbarossa", "Henry", "Mako"];
export const Character = ({ character = 0, animation = "Idle", ...props }) => {
const { scene, animations } = useGLTF(
`/models/Characters_${CHARACTERS[character]}.gltf`
);
const ref = useRef();
const { actions } = useAnimations(animations, ref);
useEffect(() => {
actions[animation].reset().fadeIn(0.5).play();
return () => actions[animation]?.fadeOut(0.5);
}, [animation]);
return (
<group {...props} ref={ref}>
<primitive object={scene} />
</group>
);
};import { Billboard, Text } from "@react-three/drei";
export const PlayerName = ({ name = "", fontSize = 0.2, ...props }) => (
<Billboard {...props}>
<Text
anchorY={"bottom"}
fontSize={fontSize}
font="/fonts/RobotoSlab-Bold.ttf"
>
{name}
</Text>
</Billboard>
);Improvements
- Add more card types like steal gems or swap gems
- Implement power-ups and special abilities
- Add particle effects and more elaborate animations for card actions
- Create custom character skins and cosmetic items
- Add a spectator mode for players who have been eliminated
- Implement team-based gameplay