Integrations

PixiJS

Draw an IRT room with PixiJS 8, with predicted movement, interpolated players, owned aim, calls and teardown.

This page builds the runners game from Engines and frameworks with PixiJS 8. It imports shared.ts from that page; read its Connecting and Teardown sections first.

PixiJS is a renderer, not a framework, so the arena is a function: it takes an initialized Application and a room ID, adds a layer to the stage, and returns a function that removes everything it added.

// arena.ts
import { CloseCode, RoomClient } from "@irt/client";
import { type Application, Container, type FederatedPointerEvent, Graphics } from "pixi.js";
import { type Input, Runner, schema, stepper } from "./shared.js";

const router = location.hostname.endsWith(".irt-serve.com") ? "wss://eu.irt.io" : location.origin;
// Pixels per world unit.
const PX = 32;

interface Sprite {
  body: Container;
  nose: Graphics;
}

export function startArena(app: Application, roomId: string, show: (status: string) => void) {
  const room = new RoomClient({ router, project: "my-game", room: roomId, schema });
  const layer = app.stage.addChild(new Container());
  const sprites = new Map<number, Sprite>();
  show("connecting");

  const offs = [
    room.on("waking", () => show("waking the room")),
    room.on("disconnected", () => show("reconnecting")),
    room.on("restored", () => show("")),
    room.on("closed", (e) => {
      if (e.code === CloseCode.SchemaMismatch) location.reload();
      else show(e.message);
    }),
    room.entities.onEnter(Runner, (r) => {
      const body = layer.addChild(new Container());
      body.addChild(new Graphics().circle(0, 0, 12).fill(r.owner === room.player ? 0x4cc9f0 : 0xf72585));
      const nose = body.addChild(new Graphics().rect(0, -2, 18, 4).fill(0xffffff));
      nose.rotation = r.angle;
      body.position.set(r.x * PX, r.y * PX);
      sprites.set(r.id, { body, nose });
    }),
    room.entities.onChange(Runner, (r, changed) => {
      const sprite = sprites.get(r.id);
      if (sprite && changed.includes("angle")) sprite.nose.rotation = r.angle;
    }),
    room.entities.onLeave(Runner, (r) => {
      sprites.get(r.id)?.body.destroy({ children: true });
      sprites.delete(r.id);
    }),
  ];

  const keys = new Set<string>();
  const down = (e: KeyboardEvent) => keys.add(e.key);
  const up = (e: KeyboardEvent) => keys.delete(e.key);
  addEventListener("keydown", down);
  addEventListener("keyup", up);

  const me = room.predict(stepper, {
    find: (r) => [...r.entities.all(Runner)].find((e) => e.owner === r.player),
    sample: (): Input => ({
      dx: (keys.has("ArrowRight") ? 1 : 0) - (keys.has("ArrowLeft") ? 1 : 0),
      dy: (keys.has("ArrowDown") ? 1 : 0) - (keys.has("ArrowUp") ? 1 : 0),
    }),
  });
  const others = room.interpolate(Runner, ["x", "y"]);

  app.stage.eventMode = "static";
  app.stage.hitArea = app.screen;
  const aim = (e: FederatedPointerEvent) => {
    const at = me.render(["x", "y"]);
    for (const r of room.entities.mine(Runner)) {
      r.angle = Math.atan2(e.global.y - at.y * PX, e.global.x - at.x * PX);
      const sprite = sprites.get(r.id);
      if (sprite) sprite.nose.rotation = r.angle;
    }
  };
  app.stage.on("pointermove", aim);

  const frame = () => {
    const local = me.render(["x", "y"]);
    for (const r of room.entities.all(Runner)) {
      const at = r.owner === room.player ? local : others.get(r);
      if (!Number.isNaN(at.x)) sprites.get(r.id)?.body.position.set(at.x * PX, at.y * PX);
    }
  };
  app.ticker.add(frame);

  const stop = () => {
    for (const off of offs) off();
    app.ticker.remove(frame);
    app.stage.off("pointermove", aim);
    removeEventListener("keydown", down);
    removeEventListener("keyup", up);
    me.stop();
    room.leave();
    layer.destroy({ children: true });
    sprites.clear();
  };
  return { room, stop };
}

And the page that runs it:

// main.ts
import { IrtError } from "@irt/client";
import { Application } from "pixi.js";
import { startArena } from "./arena.js";
import { Wallet } from "./shared.js";

const app = new Application();
await app.init({ resizeTo: window, background: "#101418", antialias: true });
document.body.appendChild(app.canvas);

const status = document.querySelector("#status")!;
const coins = document.querySelector("#coins")!;
const buy = document.querySelector<HTMLButtonElement>("#buy")!;

const arena = startArena(app, new URLSearchParams(location.search).get("room") ?? "arena", (s) => (status.textContent = s));
document.querySelector("#leave")!.addEventListener("click", arena.stop);
arena.room.entities.onChange(Wallet, (w) => (coins.textContent = `${w.coins} coins`));

buy.addEventListener("click", async () => {
  try {
    const reply = await arena.room.call("buy", { item: "dash" });
    coins.textContent = `${reply.coins} coins`;
  } catch (e) {
    if (e instanceof IrtError) status.textContent = e.message;
    else throw e;
  }
});

arena.room.ready.then(
  () => {
    buy.disabled = false;
    coins.textContent = `${arena.room.private!.coins} coins`;
  },
  // The closed listener in startArena has reported it.
  () => {},
);

How it fits together:

  • Listeners before ready. startArena returns as soon as the client is constructed, with every listener in place, so the runners already in the room enter as sprites. The buy button stays disabled until ready resolves, because call throws before then.
  • The ticker draws. app.ticker runs once per animation frame; the frame callback places every runner from the prediction or the interpolator. onEnter places a new sprite at the entity’s current position, so it doesn’t flash at the origin for a frame.
  • Aim is written, not sampled. The stage gets pointer events only with eventMode = "static" and a hitArea, here the whole screen. The handler writes the owned runner’s angle and turns its nose at once; other clients see it through onChange.
  • Teardown. stop removes the IRT listeners first, then the ticker callback, the pointer and key handlers, then leaves the room and destroys the layer with its children. It leaves the Application alone, since the arena didn’t create it.

Voice works the same in every engine; see the Phaser guide for joinVoice with a talk button and Proximity voice for the details.

Common mistakes

  • Destroying a sprite without { children: true }. The runner’s circle and nose stay in memory; PixiJS 8 doesn’t destroy children by default.
  • Using app.ticker.deltaMS to move runners. Movement comes from the room’s ticks: the prediction steps at the room’s fixed timestep, and the interpolator places remote runners by the room’s clock. The ticker only says when to draw.
  • Pointer events on the stage with no hitArea. An empty part of the stage then gets no events, and aim stops when the pointer leaves a sprite.

View this page as Markdown