# 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](/docs/integrations/overview) with PixiJS 8. It
imports `shared.ts` from that page; read its
[Connecting](/docs/integrations/overview#connecting) and
[Teardown](/docs/integrations/overview#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.

```ts
// 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:

```ts
// 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](/docs/integrations/phaser) for `joinVoice` with a talk
button and [Proximity voice](/docs/guides/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.
