The server was running. The Rust was making sense. But on the client side, I had a problem I hadn't anticipated: React and real-time rendering don't want the same things.
React is built around a simple idea — your UI is a function of state. State changes, React re-renders, the DOM updates. It's elegant, and it's the mental model I've used for years. But a game renderer running at 60fps doesn't work this way. You don't want to trigger a React re-render every 16 milliseconds. You want to reach into a canvas and move pixels directly.
This post is about mounting an imperative game engine inside a declarative framework, and all the places where the two models clash.
the escape hatch
React gives you exactly one way to say "I need to touch something outside the React tree": useRef plus useEffect. The ref gives you a DOM node. The effect gives you a place to run setup code that React won't interfere with.
Here's the skeleton:
function App() {
const canvasRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const init = async () => {
const app = new Application();
await app.init({
width: GRID_COLUMNS * TILE_SIZE,
height: GRID_ROWS * TILE_SIZE,
});
canvasRef.current?.appendChild(app.canvas);
// everything else happens here — outside React
};
init();
return () => {
// cleanup: destroy the PixiJS app
};
}, []);
return <div ref={canvasRef} />;
}
The [] dependency array means this runs once on mount. Inside init, we create a PixiJS Application, append its canvas to the div, and from that point forward, React has nothing to do with rendering. The game lives entirely in the useEffect closure.
One thing that caught me off guard: app.init() is async. PixiJS v8 moved initialization to a promise-based API, which means you can't just construct and go — you need the async wrapper inside the effect. A useEffect callback can't be async directly, so you define init inside it and call it immediately.
the StrictMode trap
React's StrictMode intentionally double-invokes effects in development to help you find missing cleanup logic. For most components, this is fine — you clean up and re-run, no harm done.
For a game renderer that creates a canvas and opens a WebSocket, it's a disaster. Two canvases. Two connections. Two sets of everything, fighting each other.
I spent an embarrassing amount of time debugging ghost players and duplicate renderings before realizing the issue. The fix was removing StrictMode from main.tsx:
// before — double mount in dev
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
// after — single mount
createRoot(document.getElementById("root")!).render(<App />);
This feels wrong if you've internalized the React best practices. StrictMode is supposed to catch bugs, and removing it feels like giving up. But the reality is: StrictMode assumes your side effects can safely run twice with no consequences. A PixiJS canvas and a WebSocket connection can't. The "correct" fix would be elaborate teardown and re-initialization logic, but for a game client that mounts once and stays mounted, removing StrictMode is the pragmatic choice.
why setState doesn't work here
My first instinct was to store game state in React state. Players move? setPlayers(newPositions). Bombs appear? setBombs(newBombs). It's how I'd build any other React app.
The problem is frame rate. The server sends game state 60 times per second. Each setState call triggers a re-render — React diffs the virtual DOM, calculates what changed, and updates. That's a lot of machinery for something that needs to happen in under 16 milliseconds, every frame.
More importantly, the rendering model is wrong. React re-renders by destroying and recreating DOM elements (or diffing to avoid it). PixiJS works by mutating existing objects in place — you change sprite.x = 100 and it's done. There's no diff step. There's no reconciliation. You just move the thing.
So the answer is: don't use React state for game state. Use React to mount the canvas and set up the WebSocket. After that, everything is direct mutation.
the factory pattern
Each game system — players, bombs, explosions, power-ups — is a factory function that takes a PixiJS Container and returns an update method. No React. No hooks. Just closures over mutable state.
Here's the core of the players manager:
export const createPlayersManager = (container: Container) => {
const players = new Map<number, Graphics>();
return {
updatePlayers: (playersData: PlayerData[]) => {
for (const player of playersData) {
let g = players.get(player.id);
if (!g) {
g = new Graphics();
g.rect(8, 8, PLAYER_SIZE, PLAYER_SIZE);
g.fill(COLORS[player.id % COLORS.length]);
container.addChild(g);
players.set(player.id, g);
}
g.x = player.x;
g.y = player.y;
}
// + cleanup for disconnected players
},
};
};
The state is a plain Map<number, Graphics> inside a closure — not React state. When updatePlayers is called 60 times per second, it mutates Graphics objects directly. g.x = player.x — that's it. No diffing, no reconciliation. New player? Create a graphic and add it to the container. Existing player? Update two properties. Player disconnects? Destroy the graphic and remove it from the map.
This pattern repeated across every system. Bombs, explosions, power-ups — they all follow the same shape: a collection of PixiJS objects, an update function that syncs them with server state, and cleanup for removed entities.
App.tsx became pure wiring:
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case ServerMessageType.WELCOME:
gridManager.drawGrid(data.map);
break;
case ServerMessageType.GAME_STATE:
bombsManager.updateBombs(data.bombs);
playersManager.updatePlayers(data.players);
explosionsManager.updateExplosions(data.explosions);
powerupsManager.updatePowerUps(data.powerups);
break;
}
};
Message comes in, dispatch to the right manager, done. No setState, no re-renders, no virtual DOM.
the z-order problem
Here's a bug that's obvious in hindsight but drove me crazy at the time: game objects kept rendering in the wrong order. A player would walk behind a bomb instead of in front of it. Explosions would hide behind the grid.
PixiJS renders children in addChild order. The first child added to a container is drawn first (behind everything else). When you have multiple systems adding graphics at different points in time — grid on startup, bombs when placed, players when they connect — the order depends on when each object was created, not on any logical layering.
The fix is explicit layers:
const gridLayer = new Container();
const entityLayer = new Container();
const playerLayer = new Container();
app.stage.addChild(gridLayer);
app.stage.addChild(entityLayer);
app.stage.addChild(playerLayer);
Grid on the bottom, entities (bombs, explosions) in the middle, players on top. Each manager gets the container it should draw into. No matter when a bomb is placed or a player connects, the z-order is correct because the layer containers are added to the stage in the right order.
If you've worked with CSS z-index, this is the same idea — but without the ability to set a number on each element. You create the ordering through container hierarchy instead.
the destructible tile problem
The grid started as a single Graphics object — one draw call for all 195 tiles (15 columns x 13 rows). Efficient and simple. Walls, floors, pillars, all drawn into one shape.
Then I added destructible blocks. When a bomb explodes, it destroys certain tiles. And you can't partially erase a Graphics object. It's one shape — you'd have to clear and redraw the entire grid every time a block is destroyed.
The solution: destructible tiles are separate Graphics objects, each stored in a Map keyed by position:
const destructibleTiles: Map<string, Graphics> = new Map();
const drawGrid = (map: TileType[]) => {
for (let row = 0; row < GRID_ROWS; row++) {
for (let col = 0; col < GRID_COLUMNS; col++) {
if (map[row * GRID_COLUMNS + col] === "destructible") {
const destructible = new Graphics();
destructible.rect(0, 0, TILE_SIZE, TILE_SIZE);
destructible.fill(TILE_COLORS.destructible);
destructible.x = col * TILE_SIZE;
destructible.y = row * TILE_SIZE;
destructibleTiles.set(
`${destructible.x}-${destructible.y}`,
destructible,
);
}
}
}
container.addChild(...destructibleTiles.values());
};
const removeDestructible = (x: number, y: number) => {
const key = `${x}-${y}`;
const destructible = destructibleTiles.get(key);
if (destructible) {
destructible.destroy();
destructibleTiles.delete(key);
}
};
The static grid (walls, floors, pillars) stays as one Graphics object — cheap and never changes. Destructible tiles are individual objects that can be independently removed. When the explosion manager processes a blast, it calls removeDestructible for each affected tile, and only that tile disappears.
This is the kind of decision you don't think about in a DOM-based app. React handles element creation and removal for you. In a canvas renderer, you're managing every object's lifecycle yourself.
React as a shell
The irony is that this is a React app where React barely does anything. It mounts a div. It runs one effect. After that, it's PixiJS closures and direct mutation all the way down.
If I were starting over, I might skip React entirely and just use vanilla TypeScript. But having React there means I can add a lobby UI, a settings screen, or a HUD later without building a UI framework from scratch. The game canvas is one component in what could become a larger app. For now, React owns the page lifecycle, and the game engine owns everything inside the canvas. They coexist because they don't try to share control.
next up
The server and client were both working. I had multiplayer movement, bombs, explosions, and power-ups. But something felt off. I'd mentioned in part 2 that players are 48 pixels in a 64-pixel grid — what I didn't explain is why, and how much that one number changed the way the game feels.
Next post: collision boxes, forgiveness, and what game developers actually mean when they talk about "feel."