Back to HomeBack to Home

Programar es divertido (a veces)

 

Intro chapa

Como muchos sabéis, formo parte del equipo de plataforma frontend de idealista, y aunque no he contado mucho sobre lo que hacemos (tampoco sé si puedo 🌚), dista mucho de los trabajos que he tenido anteriormente. Está muy chulo lo que hacemos, que además es lo que a mi me gusta: cacharrear. Romper cosas, ver cómo podemos mejorar otras y así en bucle, pero eso no quita que a veces eche de menos trabajar en producto.

Estos últimos días, mi novia y yo hemos estado haciendo un curso de Next.js, para ponernos un poco al día con este tema, y volver a estar haciendo este tipo de cosas me ha devuelto un poco el espíritu joven que tenía cuando empecé a trabajar en producto.

"Vaya chapa" diréis. Pues sí, es mi blog y es lo que hay. Pero bueno, hoy vengo a hablaros de algo que he estado probando: hacer aplicaciones "multijugador" desde cero. Hay muchas librerías que ya se encargan de hacer esto, como partykit. Pero, ¿dónde está la gracia de usar algo que ya está hecho? 🤔 (aquí se ve que pertenezco al equipo que pertenezco).

 

Nuestro proyecto

Es muy, pero que muy básico: vamos a abrir varios navegadores con nuestra aplicaciones y seremos capaces de poder ver el curso de los demás moviéndose por pantalla. Es simple, pero de ahí se puede escalar a lo que sea. Hay APIs nativas del navegador, como Event Messaging, que nos permiten comunicarnos entre ventanas, pero como quería probar otras librerías he decidio no tirar por ahí.

 

Servidor

Estoy usando bun para ejecutar el servidor (bun run server.ts).

pnpm init
pnpm add express cors socket.io
pnpm add -D @types/express typescript

Estas son nuestras dependencias para el servidor. Esta parte es la más aburrida, muy directa y sin nada "guay":

import { createServer } from "http";
import { Server } from "socket.io";
import Express from "express";
import cors from "cors";

const app = Express();

app.use(
    cors({
        origin: "*",
    })
);

const server = createServer(app);
const io = new Server(server, {
    cors: {
        origin: "*",
    },
});

io.on("connection", (socket) => {
    console.log("New client connected");

    // Aquí gestionaremos lo que he llamado "playground"

    // Aquí es donde se gestionaremos la posición del jugador, que lo implementaremos después

    socket.on("disconnect", () => {
        console.log("Client disconnected");
    });
});

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
    console.log(`Server listening on port ${PORT}`);
});

Lo que os decía: no hay ningún misterio. Lo interesante es lo que viene ahora. Los playground es algo que me he inventado yo, porque seguro que tiene algún otro nombre, pero bueno. Cada "playground" es como una sala, donde están todos los jugadores de dicha sala. Están identificados por números, aunque solo usaremos el número 1.

socket.on("join-playground", (playgroundId: string) => {
    console.log(`Client joined playground: ${playgroundId}`);
    socket.join(`playground-${playgroundId}`);
});

Sencillo, ¿verdad? Cuando reciba el evento "join-playground", el cliente se unirá a la sala correspondiente, que le llegará como argumento.

socket.on(
    "cursor-move",
    (data: {
        playgroundId: string;
        userId: string;
        position: { x: number; y: number };
    }) => {
        socket
            .to(`playground-${data.playgroundId}`)
            .emit("cursor-moved", data);
    }
);

Y con este evento, que es el último que necesitamos, informamos que hay un jugador moviendo el ratón, en el playground que lo está moviendo y la posición. El servidor no tiene más, por suerte.

 

Cliente

Aquí he usado una app hecha en Next, como decía. Porque podemos.

pnpx create-next-app@latest multiplayer-from-scratch

Las opciones por default.

No sé si conocéis cómo funciona Next, pero es un framework enfocado en SSR con React, aunque ahora ya tiene muchas otras funcionalidades.

Nosotros no vamos a complicanos la vida con nada, de hecho mirad:

// app/page.tsx

import Link from "next/link";

export default function Home() {
  return (
    <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
      <Link href="/playground/1">Playground 1</Link>
    </div>
  );
}

La página de inicio es simplemente un link a nuestro playground. No he quitado ni los estilos iniciales 😇.

Si veis el link, apunta a /playground/1. Eso es porque he creado una página dinámica, que se encargará de gestionar el playground. Y aquí es donde empieza lo bueno:

Dentro del directorio app, creamos la siguiente estructura: playground/[playgroundId]/page.tsx

[playgroundId] indica que playground/X será una página dinámica, donde X será el id del playground.

Muchos de los que estáis aquí asumo que conocéis React, aun así, vamos a ir poco a poco:

"use client";

import { useEffect, useRef, useState } from "react";
import io, { Socket } from "socket.io-client";

interface CursorPosition {
  x: number;
  y: number;
}

interface Cursors {
  [userId: string]: CursorPosition;
}

Aquí estamos importando las cosas que necesitamos. io es la función que nos permite conectarnos al servidor, y Socket es el tipo que nos devuelve. También hemos definido dos interfaces, una para la posición del ratón y otra para los cursores de los demás jugadores.

export default function Playground({
  params,
}: {
  params: { playgroundId: string };
}) {
  const [socket, setSocket] = useState<Socket | null>(null);
  const [cursors, setCursors] = useState<Cursors>({});

  const connected = useRef(false);
  const { playgroundId } = params;

  [...]

}

Como es una ruta dinámica, Next nos inyecta la prop params, así podemos acceder a la id del playground. También hemos definido un estado para el socket y otro para los cursores de los demás jugadores. connected es un ref que nos indica si estamos conectados al servidor o no.

  useEffect(() => {
    const initSocket = async () => {
      const newSocket = io("http://localhost:3000");

      setSocket(newSocket);
    };

    if (!connected.current) {
      initSocket();
      connected.current = true;
    }
  }, []);

  useEffect(() => {
    if (socket && playgroundId) {
      socket.emit("join-playground", playgroundId);

      socket.on(
        "cursor-moved",
        (data: { userId: string; position: CursorPosition }) => {
          setCursors((prev) => ({ ...prev, [data.userId]: data.position }));
        }
      );

      return () => {
        socket.off("cursor-moved");
      };
    }
  }, [socket, playgroundId]);

Si me conocéis sabéis que odio los useEffect. Por favor, si estáis haciendo SPAs, por favor, de verdad, usad React Query. Aquí basicamente estamos conectándonos al socket en el primer efecto, mientras que en el segundo estamos entrando en el playground y escuchando los movimientos de los demás jugadores.

  const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
    if (socket && playgroundId) {
      const position = { x: e.clientX, y: e.clientY };
      socket.emit("cursor-move", {
        playgroundId: playgroundId,
        userId: socket.id,
        position,
      });
    }
  };

  return (
    <div
      onMouseMove={handleMouseMove}
      style={{ width: "100vw", height: "100vh", position: "relative" }}
    >
      {Object.entries(cursors).map(([userId, position]) => (
        <div
          key={userId}
          style={{
            position: "absolute",
            left: position.x,
            top: position.y,
            width: 10,
            height: 10,
            borderRadius: "50%",
            backgroundColor: "red",
          }}
        />
      ))}
    </div>
  );

Por último, hemos definido un handler para el movimiento del ratón, que emitirá un evento al servidor con la posición del ratón. Y luego, simplemente, pintamos los cursores de los demás jugadores.

 

Prueba

Si habéis seguido los pasos, deberíais poder abrir varios navegadores y ver cómo se mueven los cursores de los demás jugadores. Es muy simple, pero es un buen punto de partida para hacer cosas más interesantes:

MP Demo

 

Conclusión

De aquí se podría ir hasta donde se quisiera: un chat, un juego, un editor de código colaborativo... o algo más fácil como asignar colores diferentes a cada jugador.

La programación puede ser muy divertida si te lo propones. No hace falta hacer cosas muy complicadas para disfrutar de lo que haces. Y si no, siempre puedes hacer un blog y contar tus movidas, como yo 😇.

Espero que para el próximo post no pasen 3 meses, pero quién sabe, la vida da muchas vueltas. Quizás en el siguiente os hablo sobre cómo estoy creando mi propio lenguaje de programación... jajajaja.

¿Te imaginas?

Anakin Padme Meme