Home / Frontend / Cómo crear un clon de Minecraft con React, Three.js y Rapier
Cómo crear un clon de Minecraft con React, Three.js y Rapier

Cómo crear un clon de Minecraft con React, Three.js y Rapier

28 October 2026 · 24 min de lectura ··

Un mundo de bloques dentro del navegador

Pocos juegos han inspirado tantos proyectos de práctica como Minecraft. Su estética de cubos, sus reglas simples y la libertad de construir lo convierten en un ejercicio ideal para aprender gráficos 3D en la web: obliga a resolver cámara, física, entrada de teclado, interacción con el ratón, gestión de estado y persistencia, pero sin la complejidad de modelos 3D detallados o animaciones de personajes.

En este tutorial construiremos paso a paso un clon de Minecraft con React que se ejecuta por completo en el navegador. El jugador podrá caminar y saltar con vista en primera persona, elegir entre cinco tipos de bloque, colocarlos y destruirlos con el ratón, y su mundo se guardará automáticamente para la siguiente visita.

La idea original de este tipo de proyecto suele montarse con React Three Fiber y el motor de física Cannon. Aquí actualizaremos el enfoque a las herramientas vigentes a finales de 2026, y lo explicaremos con una estructura y un código propios. Trabajaremos con estas versiones estables, confirmadas al momento de escribir:

  • React 19.3, publicado en octubre de 2026.
  • Three.js r186, es decir, el paquete [email protected].
  • React Three Fiber 9.7, el renderizador de React para Three.js.
  • Drei 10.7, la colección de utilidades del ecosistema de React Three Fiber.
  • React Three Rapier 2.2, el puente hacia el motor de física Rapier.
  • Zustand 5.0, para el estado global.
  • Vite 8, como herramienta de desarrollo y empaquetado.

Existen versiones alfa de React Three Fiber 10 y Drei 11, pero no son estables, así que las dejaremos fuera. El código está escrito en JavaScript con JSX para que sea accesible a cualquier desarrollador de React; migrarlo a TypeScript es directo.

Qué vamos a construir y cómo se organiza

Antes de escribir código conviene tener claro el mapa del proyecto. El juego tendrá estas piezas:

  • Un cielo con sol, generado de forma procedural.
  • Un suelo de césped amplio sobre el que se apoya todo.
  • Un jugador con cuerpo físico, gravedad, salto y colisiones.
  • Una cámara en primera persona controlada con el ratón.
  • Bloques con textura que se colocan con clic y se eliminan con Alt más clic.
  • Un selector de material con las teclas del 1 al 5.
  • Persistencia automática del mundo en el almacenamiento del navegador.

La organización de archivos que usaremos es la siguiente:

mundo-cubos/
├── index.html
├── package.json
├── vite.config.js
├── public/
│   └── texturas/
│       ├── tierra.png
│       ├── cesped.png
│       ├── vidrio.png
│       ├── madera.png
│       └── tronco.png
└── src/
    ├── main.jsx
    ├── App.jsx
    ├── estilos.css
    ├── store/
    │   └── useMundo.js
    ├── hooks/
    │   └── useSeleccionNumerica.js
    ├── texturas.js
    └── componentes/
        ├── Escena.jsx
        ├── Suelo.jsx
        ├── Jugador.jsx
        ├── Bloque.jsx
        ├── Bloques.jsx
        ├── BarraMateriales.jsx
        └── MenuMundo.jsx

Separar el estado en store, la lógica de entrada en hooks y cada elemento visual en su propio componente facilita mucho el mantenimiento cuando el proyecto crece.

Crear el proyecto con Vite e instalar dependencias

Necesitas una versión reciente de Node.js compatible con Vite 8. Crea el proyecto con la plantilla oficial de React:

npm create vite@latest mundo-cubos -- --template react
cd mundo-cubos
npm install

Después instala las librerías del ecosistema 3D y de estado:

npm install three @react-three/fiber @react-three/drei @react-three/rapier zustand

Comprueba las versiones resueltas con:

npm ls three @react-three/fiber @react-three/drei @react-three/rapier zustand react

Una salida típica tendrá un aspecto parecido a este:

[email protected]
├── @react-three/[email protected]
├── @react-three/[email protected]
├── @react-three/[email protected]
├── [email protected]
├── [email protected]
└── [email protected]

Si npm muestra advertencias de dependencias peer entre React Three Fiber y la versión de React, revisa la página del paquete: cada versión menor de Fiber declara el rango de React con el que es compatible, y conviene que ambos estén alineados.

Un cambio importante respecto a los tutoriales de hace unos años es el motor de física. El paquete @react-three/cannon se basaba en Cannon, un motor escrito en JavaScript que lleva tiempo con poco mantenimiento. Hoy la opción más utilizada en el ecosistema es Rapier, escrito en Rust y compilado a WebAssembly, que ofrece mejor rendimiento y una API muy cómoda a través de @react-three/rapier.

Preparar las texturas pixeladas

El estilo visual depende por completo de las texturas. Necesitas cinco imágenes PNG pequeñas, idealmente de 16 por 16 píxeles, guardadas en public/texturas. Puedes dibujarlas tú mismo con cualquier editor de pixel art o usar paquetes de texturas con licencia libre. Evita copiar las texturas oficiales del juego original, ya que están protegidas por derechos de autor.

Centraliza las rutas en un único módulo para no repetir cadenas por todo el proyecto:

// src/texturas.js
export const MATERIALES = ["tierra", "cesped", "vidrio", "madera", "tronco"];

export const RUTAS_TEXTURA = Object.fromEntries(
    MATERIALES.map((nombre) => [nombre, `/texturas/${nombre}.png`])
);

Hay un detalle técnico clave: Three.js suaviza por defecto las texturas cuando se ven ampliadas, lo que convierte el pixel art en una mancha borrosa. Para conservar los bordes nítidos hay que usar el filtro NearestFilter. Lo aplicaremos en cada componente que cargue texturas mediante una pequeña función auxiliar:

// src/texturas.js (continuación)
import { NearestFilter, SRGBColorSpace } from "three";

export function prepararPixelArt(textura) {
    const lista = Array.isArray(textura) ? textura : [textura];
    lista.forEach((t) => {
        t.magFilter = NearestFilter;
        t.colorSpace = SRGBColorSpace;
    });
}

Asignar el espacio de color sRGB evita que las texturas se vean lavadas, algo que desde hace varias versiones de Three.js es la configuración recomendada para imágenes de color.

Modelar el mundo con Zustand

El estado del juego cabe en muy pocas piezas: la lista de bloques colocados, el material activo y unas cuantas acciones para modificarlos. Zustand encaja perfectamente porque permite leer y actualizar el estado desde cualquier componente, dentro o fuera del Canvas 3D, sin envolver la aplicación en proveedores.

Además, su middleware persist resuelve el guardado automático: cada cambio se escribe en localStorage y se rehidrata al recargar la página. En muchos tutoriales antiguos esto se hacía manualmente con funciones de lectura y escritura; con el middleware basta con declararlo.

// src/store/useMundo.js
import { create } from "zustand";
import { persist } from "zustand/middleware";

const mismaPosicion = (a, b) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2];

export const useMundo = create(
    persist(
        (set, get) => ({
            bloques: [],
            materialActivo: "tierra",

            colocarBloque: (posicion) => {
                const ocupado = get().bloques.some((b) =>
                    mismaPosicion(b.posicion, posicion)
                );
                if (ocupado) return;
                set((estado) => ({
                    bloques: [
                        ...estado.bloques,
                        {
                            id: crypto.randomUUID(),
                            posicion,
                            material: estado.materialActivo,
                        },
                    ],
                }));
            },

            quitarBloque: (posicion) =>
                set((estado) => ({
                    bloques: estado.bloques.filter(
                        (b) => !mismaPosicion(b.posicion, posicion)
                    ),
                })),

            elegirMaterial: (material) => set({ materialActivo: material }),

            reiniciarMundo: () => set({ bloques: [] }),
        }),
        {
            name: "mundo-cubos-v1",
            partialize: (estado) => ({ bloques: estado.bloques }),
        }
    )
);

Algunas decisiones merecen explicación. Los identificadores se generan con crypto.randomUUID(), disponible de forma nativa en los navegadores modernos, por lo que ya no necesitamos dependencias externas para crear IDs únicos. La función colocarBloque rechaza posiciones ocupadas para evitar cubos duplicados en el mismo hueco. Y la opción partialize hace que solo se guarden los bloques: el material activo es una preferencia de sesión que no merece persistirse.

El nombre de la clave incluye un sufijo de versión. Si en el futuro cambias la forma de los datos, puedes subir el número o usar la opción migrate del middleware para transformar mundos antiguos sin romper la carga.

Montar el lienzo 3D, el cielo y la iluminación

El punto de entrada visual es el componente Canvas de React Three Fiber. Dentro de él, cada elemento de JSX se traduce a un objeto de Three.js: <mesh> crea un Mesh, <boxGeometry> una BoxGeometry, y así sucesivamente.

Primero los estilos básicos para que el lienzo ocupe toda la ventana y para dibujar una mira en el centro:

/* src/estilos.css */
html,
body,
#root {
    margin: 0;
    height: 100%;
    overflow: hidden;
    background: #87b6e8;
    font-family: system-ui, sans-serif;
}

.mira {
    position: fixed;
    top: 50%;
    left: 50%;
    translate: -50% -50%;
    color: white;
    font-size: 28px;
    pointer-events: none;
    text-shadow: 0 0 3px black;
}

Ahora la escena 3D. Separamos su contenido en un componente propio para mantener App.jsx limpio:

// src/componentes/Escena.jsx
import { Sky, PointerLockControls } from "@react-three/drei";
import { Physics } from "@react-three/rapier";
import { Suelo } from "./Suelo";
import { Jugador } from "./Jugador";
import { Bloques } from "./Bloques";

export function Escena() {
    return (
        <>
            <Sky sunPosition={[120, 40, 80]} turbidity={6} />
            <ambientLight intensity={0.7} />
            <directionalLight
                position={[30, 50, 20]}
                intensity={1.6}
                castShadow
                shadow-mapSize={[2048, 2048]}
            />

            <Physics gravity={[0, -20, 0]}>
                <Jugador />
                <Suelo />
                <Bloques />
            </Physics>

            <PointerLockControls />
        </>
    );
}

El componente Sky de Drei genera un cielo atmosférico realista a partir de la posición del sol, sin necesidad de imágenes. La gravedad se fija en el doble de la terrestre porque, en juegos de este estilo, una caída más rápida da una sensación de control más firme; puedes ajustarla a tu gusto.

Controles en primera persona con Pointer Lock

Para mover la vista con el ratón como en cualquier juego de disparos o de construcción, necesitamos la API Pointer Lock del navegador. Esta API oculta el cursor y entrega los movimientos relativos del ratón sin límite de pantalla. El componente PointerLockControls de Drei la integra con la cámara de Three.js: basta con hacer clic en el lienzo para capturar el ratón, y la tecla Escape lo libera.

Implementar controles en primera persona tiene un detalle que muchos tutoriales pasan por alto. Cuando el puntero está bloqueado, la posición del cursor deja de cambiar, pero React Three Fiber sigue calculando las intersecciones con los objetos desde esa posición congelada. Si el usuario hizo clic en una esquina para bloquear el ratón, todos los clics posteriores apuntarán a esa esquina en lugar del centro de la pantalla.

La solución es personalizar el cálculo de eventos para que el rayo salga siempre desde el centro, justo donde está la mira:

// src/App.jsx
import { Suspense } from "react";
import { Canvas, events } from "@react-three/fiber";
import { KeyboardControls } from "@react-three/drei";
import { Escena } from "./componentes/Escena";
import { BarraMateriales } from "./componentes/BarraMateriales";
import { MenuMundo } from "./componentes/MenuMundo";
import { useSeleccionNumerica } from "./hooks/useSeleccionNumerica";

const eventosDesdeElCentro = (store) => ({
    ...events(store),
    compute: (_evento, estado) => {
        estado.pointer.set(0, 0);
        estado.raycaster.setFromCamera(estado.pointer, estado.camera);
    },
});

const mapaTeclas = [
    { name: "adelante", keys: ["KeyW", "ArrowUp"] },
    { name: "atras", keys: ["KeyS", "ArrowDown"] },
    { name: "izquierda", keys: ["KeyA", "ArrowLeft"] },
    { name: "derecha", keys: ["KeyD", "ArrowRight"] },
    { name: "saltar", keys: ["Space"] },
];

export default function App() {
    useSeleccionNumerica();

    return (
        <KeyboardControls map={mapaTeclas}>
            <Canvas
                shadows="percentage"
                camera={{ fov: 70, near: 0.1, far: 500 }}
                events={eventosDesdeElCentro}
            >
                <Suspense fallback={null}>
                    <Escena />
                </Suspense>
            </Canvas>

            <div className="mira">+</div>
            <BarraMateriales />
            <MenuMundo />
        </KeyboardControls>
    );
}

Hay dos elecciones de configuración que conviene comentar. El valor shadows="percentage" activa sombras con filtrado PCF. Three.js r186 eliminó el tipo PCFSoftShadowMap y ahora cae automáticamente en PCFShadowMap con una advertencia, así que declarar el tipo explícitamente evita mensajes en consola. Por otro lado, Suspense es imprescindible: tanto la carga de texturas como la inicialización del módulo WebAssembly de Rapier son asíncronas, y React necesita saber qué mostrar mientras tanto.

El archivo main.jsx queda prácticamente igual que en la plantilla, solo importando nuestros estilos:

// src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./estilos.css";

createRoot(document.getElementById("root")).render(
    <StrictMode>
        <App />
    </StrictMode>
);

Leer el teclado sin provocar re-renderizados

El movimiento del jugador necesita saber, en cada fotograma, qué teclas están pulsadas. Una aproximación habitual es un hook propio con useState y useEffect que escucha keydown y keyup. Funciona, pero cada pulsación provoca un re-renderizado del componente, algo innecesario cuando el valor solo se consulta dentro del bucle de animación.

Drei ofrece KeyboardControls, que ya colocamos en App.jsx. Su hook useKeyboardControls devuelve una función que lee el estado actual sin suscribirse a cambios, ideal para usarla dentro de useFrame.

Para el selector de materiales sí queremos reaccionar a cada pulsación, así que escribimos un hook propio sencillo:

// src/hooks/useSeleccionNumerica.js
import { useEffect } from "react";
import { useMundo } from "../store/useMundo";
import { MATERIALES } from "../texturas";

export function useSeleccionNumerica() {
    const elegirMaterial = useMundo((estado) => estado.elegirMaterial);

    useEffect(() => {
        const alPulsar = (evento) => {
            const indice = Number(evento.key) - 1;
            if (
                Number.isInteger(indice) &&
                indice >= 0 &&
                indice < MATERIALES.length
            ) {
                elegirMaterial(MATERIALES[indice]);
            }
        };
        window.addEventListener("keydown", alPulsar);
        return () => window.removeEventListener("keydown", alPulsar);
    }, [elegirMaterial]);
}

La función de limpieza que devuelve el efecto es fundamental: sin ella, cada montaje del componente añadiría un nuevo listener, y en modo estricto de desarrollo React monta los efectos dos veces precisamente para detectar este tipo de fugas.

El suelo: plano visible y colisionador físico

El suelo combina dos cosas independientes: una malla que se ve y un colisionador que la física entiende. Con React Three Fiber y Rapier podemos agruparlos dentro de un RigidBody de tipo fijo, es decir, un cuerpo que nunca se mueve pero contra el que todo choca.

// src/componentes/Suelo.jsx
import { useTexture } from "@react-three/drei";
import { RigidBody, CuboidCollider } from "@react-three/rapier";
import { RepeatWrapping } from "three";
import { useMundo } from "../store/useMundo";
import { RUTAS_TEXTURA, prepararPixelArt } from "../texturas";

const TAMANO = 100;

export function Suelo() {
    const colocarBloque = useMundo((estado) => estado.colocarBloque);

    const cesped = useTexture(RUTAS_TEXTURA.cesped, (t) => {
        prepararPixelArt(t);
        t.wrapS = t.wrapT = RepeatWrapping;
        t.repeat.set(TAMANO, TAMANO);
    });

    const alHacerClic = (evento) => {
        evento.stopPropagation();
        const { x, z } = evento.point;
        colocarBloque([Math.round(x), 0, Math.round(z)]);
    };

    return (
        <RigidBody type="fixed" colliders={false}>
            <CuboidCollider
                args={[TAMANO / 2, 0.5, TAMANO / 2]}
                position={[0, -1, 0]}
            />
            <mesh
                rotation-x={-Math.PI / 2}
                position={[0, -0.5, 0]}
                receiveShadow
                onClick={alHacerClic}
            >
                <planeGeometry args={[TAMANO, TAMANO]} />
                <meshStandardMaterial map={cesped} />
            </mesh>
        </RigidBody>
    );
}

La geometría del mundo sigue una convención simple: cada bloque mide una unidad y su centro está en coordenadas enteras. El primer nivel de bloques tiene centro en y = 0, así que su cara inferior queda en y = -0.5, justo donde colocamos la superficie del suelo. El colisionador es una caja de medio metro de grosor cuya cara superior coincide con el plano visible.

La textura se repite cien veces gracias a RepeatWrapping, de modo que cada metro del suelo muestra un cuadro de césped. El segundo argumento de useTexture es un callback que se ejecuta una sola vez al cargar la imagen, el lugar correcto para configurarla en lugar de mutarla en cada render.

El jugador: cuerpo físico, movimiento y salto

El jugador no tiene modelo visible, ya que la cámara está en sus ojos. Lo que sí necesita es un cuerpo físico con forma de cápsula, que se desliza mejor por los bordes de los bloques que una caja. En cada fotograma leeremos el teclado, calcularemos la dirección según hacia dónde mira la cámara y ajustaremos la velocidad del cuerpo.

// src/componentes/Jugador.jsx
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { useKeyboardControls } from "@react-three/drei";
import { RigidBody, CapsuleCollider } from "@react-three/rapier";
import { Vector3 } from "three";

const VELOCIDAD = 5;
const IMPULSO_SALTO = 7;
const ALTURA_OJOS = 0.7;

const direccion = new Vector3();
const frente = new Vector3();
const lateral = new Vector3();

export function Jugador() {
    const cuerpo = useRef(null);
    const [, leerTeclas] = useKeyboardControls();

    useFrame(({ camera }) => {
        const rb = cuerpo.current;
        if (!rb) return;

        const { adelante, atras, izquierda, derecha, saltar } = leerTeclas();

        const p = rb.translation();
        camera.position.set(p.x, p.y + ALTURA_OJOS, p.z);

        frente.set(0, 0, Number(atras) - Number(adelante));
        lateral.set(Number(izquierda) - Number(derecha), 0, 0);
        direccion
            .subVectors(frente, lateral)
            .normalize()
            .multiplyScalar(VELOCIDAD)
            .applyEuler(camera.rotation);

        const v = rb.linvel();
        const enElSuelo = Math.abs(v.y) < 0.05;
        const vy = saltar && enElSuelo ? IMPULSO_SALTO : v.y;

        rb.setLinvel({ x: direccion.x, y: vy, z: direccion.z }, true);
    });

    return (
        <RigidBody
            ref={cuerpo}
            colliders={false}
            position={[0, 4, 5]}
            enabledRotations={[false, false, false]}
            ccd
        >
            <CapsuleCollider args={[0.5, 0.35]} />
        </RigidBody>
    );
}

Vale la pena desmenuzar la lógica. Los vectores auxiliares se crean una sola vez fuera del componente, porque instanciar objetos nuevos sesenta veces por segundo genera trabajo extra para el recolector de basura. El vector frente vale -1, 0 o 1 en el eje Z según las teclas W y S, y lateral hace lo mismo en el eje X con A y D. Al restarlos y normalizar obtenemos una dirección de longitud uno, que evita que moverse en diagonal sea más rápido que en línea recta. Finalmente, applyEuler rota esa dirección según la orientación de la cámara, de modo que “adelante” siempre es hacia donde mira el jugador.

Solo aplicamos los componentes X y Z de esa dirección a la velocidad. El componente vertical lo gestiona la gravedad, salvo en el instante del salto. La detección de suelo usa una heurística sencilla: si la velocidad vertical es casi nula, el jugador está apoyado. No es perfecta, porque en el punto más alto de un salto la velocidad también es cero, pero para un prototipo es suficiente. Más adelante veremos una alternativa con rayos.

Por último, enabledRotations bloquea la rotación del cuerpo para que la cápsula no se vuelque al chocar, y ccd activa la detección continua de colisiones, que impide atravesar bloques cuando la velocidad es alta.

Colocar y quitar bloques con el ratón

Cada bloque es un cubo con textura y un cuerpo físico fijo. La parte interesante es cómo decidir dónde poner el siguiente bloque cuando el usuario hace clic sobre uno existente. Three.js nos entrega en el evento la cara intersectada, y su vector normal indica hacia dónde apunta esa cara. Si sumamos la normal a la posición del bloque, obtenemos exactamente la celda vecina.

// src/componentes/Bloque.jsx
import { useState } from "react";
import { useTexture } from "@react-three/drei";
import { RigidBody } from "@react-three/rapier";
import { useMundo } from "../store/useMundo";
import { RUTAS_TEXTURA, prepararPixelArt } from "../texturas";

export function Bloque({ posicion, material }) {
    const [resaltado, setResaltado] = useState(false);
    const colocarBloque = useMundo((estado) => estado.colocarBloque);
    const quitarBloque = useMundo((estado) => estado.quitarBloque);
    const textura = useTexture(RUTAS_TEXTURA[material], prepararPixelArt);

    const alHacerClic = (evento) => {
        evento.stopPropagation();
        if (evento.altKey) {
            quitarBloque(posicion);
            return;
        }
        const n = evento.face.normal;
        const [x, y, z] = posicion;
        colocarBloque([
            x + Math.round(n.x),
            y + Math.round(n.y),
            z + Math.round(n.z),
        ]);
    };

    const esVidrio = material === "vidrio";

    return (
        <RigidBody type="fixed" colliders="cuboid" position={posicion}>
            <mesh
                castShadow
                receiveShadow
                onClick={alHacerClic}
                onPointerMove={(e) => {
                    e.stopPropagation();
                    setResaltado(true);
                }}
                onPointerOut={() => setResaltado(false)}
            >
                <boxGeometry args={[1, 1, 1]} />
                <meshStandardMaterial
                    map={textura}
                    color={resaltado ? "#bbbbbb" : "white"}
                    transparent={esVidrio}
                    opacity={esVidrio ? 0.55 : 1}
                />
            </mesh>
        </RigidBody>
    );
}

Estas son las piezas clave para colocar y eliminar bloques de forma fiable. El stopPropagation evita que el clic atraviese el cubo y llegue también al suelo, lo que crearía dos bloques a la vez. El redondeo de la normal elimina imprecisiones de coma flotante, ya que una normal de valor 0.9999999 produciría coordenadas que nunca coincidirían con la cuadrícula. Y el resaltado al pasar el puntero, que con nuestra configuración de eventos ocurre cuando la mira apunta al bloque, da una pista visual de qué cubo se verá afectado.

El color del material se multiplica por la textura, así que un tono gris oscurece el bloque sin necesidad de una segunda textura. Para el vidrio activamos la transparencia con una opacidad intermedia.

El componente que pinta la lista completa es trivial:

// src/componentes/Bloques.jsx
import { useMundo } from "../store/useMundo";
import { Bloque } from "./Bloque";

export function Bloques() {
    const bloques = useMundo((estado) => estado.bloques);
    return bloques.map((b) => (
        <Bloque key={b.id} posicion={b.posicion} material={b.material} />
    ));
}

Usar el id como clave, y no el índice del arreglo, garantiza que React reutilice correctamente cada componente cuando se elimina un bloque del medio de la lista.

Barra de materiales y menú del mundo

La interfaz 2D vive fuera del Canvas, como HTML normal superpuesto. La barra de materiales muestra las cinco texturas y resalta la activa. Para no tapar la vista permanentemente, aparece durante dos segundos cada vez que el jugador cambia de material:

// src/componentes/BarraMateriales.jsx
import { useEffect, useState } from "react";
import { useMundo } from "../store/useMundo";
import { MATERIALES, RUTAS_TEXTURA } from "../texturas";

export function BarraMateriales() {
    const activo = useMundo((estado) => estado.materialActivo);
    const [visible, setVisible] = useState(false);

    useEffect(() => {
        setVisible(true);
        const temporizador = setTimeout(() => setVisible(false), 2000);
        return () => clearTimeout(temporizador);
    }, [activo]);

    if (!visible) return null;

    return (
        <div
            style={{
                position: "fixed",
                bottom: 24,
                left: "50%",
                translate: "-50% 0",
                display: "flex",
                gap: 8,
            }}
        >
            {MATERIALES.map((nombre, i) => (
                <figure
                    key={nombre}
                    style={{ margin: 0, textAlign: "center", color: "white" }}
                >
                    <img
                        src={RUTAS_TEXTURA[nombre]}
                        alt={nombre}
                        width={48}
                        height={48}
                        style={{
                            imageRendering: "pixelated",
                            outline:
                                nombre === activo
                                    ? "3px solid white"
                                    : "1px solid #0006",
                        }}
                    />
                    <figcaption>{i + 1}</figcaption>
                </figure>
            ))}
        </div>
    );
}

La propiedad CSS image-rendering: pixelated hace en el HTML lo mismo que NearestFilter en WebGL: mantiene los píxeles nítidos al ampliar.

El menú permite reiniciar el mundo y exportarlo como archivo JSON, útil para compartir construcciones o hacer copias de seguridad:

// src/componentes/MenuMundo.jsx
import { useMundo } from "../store/useMundo";

export function MenuMundo() {
    const bloques = useMundo((estado) => estado.bloques);
    const reiniciarMundo = useMundo((estado) => estado.reiniciarMundo);

    const exportar = () => {
        const blob = new Blob([JSON.stringify(bloques, null, 2)], {
            type: "application/json",
        });
        const enlace = document.createElement("a");
        enlace.href = URL.createObjectURL(blob);
        enlace.download = "mi-mundo.json";
        enlace.click();
        URL.revokeObjectURL(enlace.href);
    };

    return (
        <div
            style={{
                position: "fixed",
                top: 12,
                left: 12,
                display: "flex",
                gap: 8,
            }}
        >
            <button onClick={exportar}>Exportar mundo</button>
            <button
                onClick={() =>
                    confirm("¿Borrar todos los bloques?") && reiniciarMundo()
                }
            >
                Reiniciar
            </button>
            <span style={{ color: "white" }}>{bloques.length} bloques</span>
        </div>
    );
}

Como el estado se persiste solo, no hace falta un botón de guardar. Gracias al middleware, la opción de guardar el mundo en localStorage queda resuelta sin código adicional: cierra la pestaña, vuelve a abrirla y tus construcciones seguirán ahí.

Para arrancar el servidor de desarrollo ejecuta:

npm run dev
  VITE v8.x  ready in 412 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose

Abre la dirección, haz clic en el lienzo para capturar el ratón y empieza a construir.

Mejorar la detección de suelo con un rayo

La heurística de velocidad vertical permite un pequeño truco no deseado: saltar otra vez en la cima del salto. Una solución más robusta es lanzar un rayo corto hacia abajo desde los pies del jugador y comprobar si toca algo. Rapier expone el mundo físico mediante el hook useRapier:

import { useRapier } from "@react-three/rapier";

// Dentro de Jugador, antes de useFrame
const { rapier, world } = useRapier();

// Dentro de useFrame, sustituyendo el cálculo de enElSuelo
const origen = { x: p.x, y: p.y - 0.86, z: p.z };
const rayo = new rapier.Ray(origen, { x: 0, y: -1, z: 0 });
const impacto = world.castRay(rayo, 0.15, true);
const enElSuelo = impacto !== null;

El origen se sitúa justo por debajo de la cápsula, cuya mitad de altura total es la suma del medio segmento y el radio. Una distancia máxima de 0.15 unidades basta para detectar el apoyo sin falsos positivos. Si la API exacta cambia entre versiones menores de Rapier, consulta la documentación de castRay, pero el concepto se mantiene: un rayo descendente es la técnica estándar de los controladores de personaje.

Rendimiento cuando el mundo crece

El enfoque de un componente por bloque es perfecto para aprender, pero no escala de forma indefinida. Cada bloque es una malla separada, lo que implica una llamada de dibujo independiente para la GPU, y además un cuerpo físico propio. Con unos pocos cientos de bloques todo va fluido; con decenas de miles, la tasa de fotogramas caerá.

La primera mejora es la instanciación. En lugar de una malla por cubo, se usa una sola InstancedMesh por material, que dibuja miles de copias en una única llamada. Drei facilita esto con los componentes Instances e Instance:

import { Instances, Instance, useTexture } from "@react-three/drei";

function CapaMaterial({ material, posiciones }) {
    const textura = useTexture(RUTAS_TEXTURA[material], prepararPixelArt);
    return (
        <Instances limit={10000}>
            <boxGeometry />
            <meshStandardMaterial map={textura} />
            {posiciones.map((p) => (
                <Instance key={p.join(",")} position={p} />
            ))}
        </Instances>
    );
}

La segunda mejora afecta a la física. Rapier admite cuerpos con muchos colisionadores, así que puedes agrupar todos los bloques en un único RigidBody fijo con un CuboidCollider por cubo, en lugar de un cuerpo por cubo. Rapier también ofrece InstancedRigidBodies para combinar instanciación y física.

La tercera, propia de los juegos de vóxeles reales, es dividir el mundo en chunks, porciones de por ejemplo 16 por 16 por 16 bloques, y generar para cada chunk una única geometría que solo contenga las caras visibles, eliminando las que quedan ocultas entre bloques vecinos. Es una técnica más avanzada, pero reduce el número de triángulos en órdenes de magnitud.

Otras recomendaciones rápidas:

  • Limita el far de la cámara para no procesar objetos lejanos.
  • Ajusta el tamaño del mapa de sombras: 2048 es un buen equilibrio; 4096 es caro en móviles.
  • Usa la herramienta Perf de la librería r3f-perf o el panel de rendimiento del navegador para medir antes de optimizar.
  • Evita crear objetos dentro de useFrame, como ya hicimos con los vectores del jugador.

Errores comunes y cómo resolverlos

Durante el desarrollo es probable que te encuentres con alguno de estos mensajes.

Error: R3F: Hooks can only be used within the Canvas component!

Estás llamando a useFrame, useThree o useTexture desde un componente que se renderiza fuera del Canvas. Solo los componentes descendientes del lienzo pueden usar los hooks de React Three Fiber. Los de Zustand, en cambio, funcionan en cualquier parte.

Error: Could not load /texturas/madera.png: undefined

La ruta no existe o el nombre del archivo no coincide. Recuerda que los archivos de public se sirven desde la raíz, sin incluir la palabra public en la URL, y que los servidores de producción en Linux distinguen mayúsculas de minúsculas.

WARNING: Multiple instances of Three.js being imported.

Dos paquetes resuelven versiones distintas de three. Ejecuta npm ls three para localizar el duplicado y alinea las versiones, o usa la opción overrides de npm para forzar una sola.

SecurityError: The user has exited the lock before this request was completed.

El navegador exige un gesto del usuario para bloquear el puntero y aplica una breve pausa tras liberar el bloqueo con Escape. Si intentas reactivarlo inmediatamente, falla. Es un comportamiento de seguridad normal; basta con volver a hacer clic tras un instante.

Otros problemas frecuentes que no generan mensajes:

  • El jugador atraviesa el suelo al iniciar: revisa que la posición inicial del cuerpo esté por encima del colisionador y que el componente Physics envuelva tanto al jugador como al suelo.
  • Se crean bloques dentro del jugador: puedes comprobar en colocarBloque la distancia a la posición actual del jugador y rechazar celdas demasiado cercanas.
  • Las texturas se ven borrosas: falta asignar NearestFilter.
  • La pantalla queda en blanco sin errores: suele ser un Suspense ausente alrededor de componentes que cargan recursos asíncronos.

Alternativas y cuándo elegir cada una

React Three Fiber no es la única forma de llevar este proyecto adelante. Conviene conocer el panorama para elegir con criterio.

Three.js sin capa de React es la opción más directa si tu aplicación no usa React o si quieres control absoluto del bucle de renderizado. Pierdes la composición declarativa y la integración con el estado, pero eliminas una capa de abstracción.

Babylon.js es un motor 3D completo, con editor, inspector integrado, física y sistema de partículas incluidos. Es una gran elección para juegos más ambiciosos, aunque su integración con React es menos idiomática.

PlayCanvas ofrece un editor visual colaborativo en el navegador y está muy orientado a juegos y experiencias comerciales. Encaja bien en equipos con perfiles de diseño que no programan.

En cuanto a la física, Rapier destaca por rendimiento y mantenimiento activo. cannon-es, la bifurcación mantenida de Cannon, sigue siendo válida para escenas sencillas. Y si tu juego no necesita física realista, una colisión casera contra la cuadrícula, comprobando qué celdas ocupa el jugador, es más ligera y predecible, y es lo que hacen muchos motores de vóxeles.

Si ya trabajas con React y quieres reutilizar su modelo de componentes, hooks y estado, la combinación de este tutorial es la más productiva para prototipar.

Ideas para seguir ampliando el juego

Con la base construida, hay muchas extensiones naturales:

  • Generación procedural del terreno con ruido Perlin o Simplex, creando colinas y valles en lugar de un plano.
  • Ciclo de día y noche moviendo la posición del sol del componente Sky y la luz direccional.
  • Un inventario con cantidades limitadas de cada material.
  • Sonidos al colocar y romper bloques con el componente PositionalAudio de Drei.
  • Multijugador sincronizando el estado de bloques mediante WebSockets o una base de datos en tiempo real.
  • Soporte táctil con un joystick virtual para dispositivos móviles, ya que Pointer Lock no está disponible en ellos.

Cada una de estas mejoras se apoya en la arquitectura que hemos montado: el estado centralizado en Zustand facilita la sincronización, y la separación en componentes permite añadir sistemas nuevos sin tocar los existentes.

Conclusiones

Construir un juego de bloques en el navegador es un ejercicio excelente para dominar el desarrollo 3D con React. A lo largo del tutorial hemos combinado React 19.3, Three.js r186, React Three Fiber 9.7, Drei, Rapier 2.2, Zustand 5 y Vite 8 para obtener un mundo explorable con cielo atmosférico, física real, cámara en primera persona, bloques que se colocan y destruyen con precisión, selector de materiales y guardado automático.

Por el camino hemos actualizado varias prácticas que aparecen en tutoriales antiguos: Rapier sustituye a Cannon como motor de física, KeyboardControls evita re-renderizados innecesarios al leer el teclado, el middleware persist reemplaza el guardado manual, crypto.randomUUID elimina dependencias para generar identificadores y el cálculo de eventos desde el centro de la pantalla corrige la interacción con el puntero bloqueado.

El resultado es una base sólida y fácil de extender. Cuando tu mundo crezca, la instanciación, la agrupación de colisionadores y la división en chunks te permitirán escalar a miles de bloques sin sacrificar fluidez. Lo más valioso, sin embargo, son los conceptos que se transfieren a cualquier proyecto 3D en la web: cómo se relacionan la escena, la física, la entrada del usuario y el estado de la aplicación.