# Pokémon Playroom: exploring React as a game engine

Source: https://anishwij.com/blog/pokemon-playroom
Category: Experiments
Published: 2025-01-15T00:00:00.000Z
Updated: 2026-09-09T00:00:00.000Z

Expressive animation, accessible controls, and multiplayer, built from familiar web patterns.

**The idea**  
I wanted a browser game with the animation and detail I enjoy in modern games, while keeping its interface accessible and its controls usable on a phone or computer.

**The experiment**  
I explored how familiar React patterns could connect the interface, players, effects, and multiplayer. The challenge was to make those systems composable while keeping direct control over movement and timing.

[Pokémon Playroom](https://labs.anishwij.com/pokemon-playroom) — A playable battle experiment combining animated character selection, camera transitions, attack effects, and multiplayer.

Things to try

1. Choose Multiplayer, then Invite player. Share the link or open it in another browser. Choose a Pokémon and Ready up in both sessions, then start the battle as host.
2. Playing solo? Choose Play Demo, select Charmander, then Start Simulation. Use Ember to try the attack effects.

I explored both playroomkit and convex for multiplayer. PlayroomKit made rooms quick to set up; I preferred Convex's reactive queries and WebSocket connection, with more control over the backend than PlayroomKit exposed.

## Composing the player

react-three-fiber brings react's declarative component patterns to threejs. I built on that to compose players and expose their actions through refs. This simplified example sits inside `EngineCanvas`:

```tsx no-copy
function Player({ health }: { health: number }) {
  const playerRef = useRef<PokemonModelHandle>(null)

  useEffect(() => {
    async function enter() {
      const player = playerRef.current
      if (!player) return

      // Wait for the entrance before starting the attack.
      await player.play('spawn')
      await player.play('attack')
    }

    void enter()
  }, [])

  return (
    <PokemonModel ref={playerRef} species='charmander'>
      <WorldHealthBar health={health} species='charmander' />
    </PokemonModel>
  )
}
```

Inside `PokemonModel`, `useImperativeHandle` defines the actions available to that ref:

```tsx no-copy
useImperativeHandle(forwardedRef, () => ({ play, reset }), [play, reset])
```

A component ref gives the parent access to a player's actions. Camera and effect systems elsewhere in the scene also need a way to find that player. Alongside the battle, I explored a zustand registry. Each entry held an ID, an object ref, and its commands.

This simplified excerpt from `EngineGroup` shows the registration pattern for a Three.js group. It adds the group on mount and removes it on unmount:

```tsx no-copy
useImperativeHandle(ref, () => internalRef.current)

useEffect(() => {
  addGroup(id, internalRef.current, { setSpawn: setIsSpawned })
  return () => removeGroup(id)
}, [id, addGroup, removeGroup])
```

Other systems could call `spawnGroup(id, true)` without passing refs through the component tree. The helper reads the Zustand store directly, looks up the registered group, and calls its `setSpawn` command.

I also tried an entity registry backed by a `Map`, with queries for entities containing particular components. React subscribed to store changes; imperative systems could read the current store directly.

These patterns let JSX describe what exists while commands control what happens. Animation and physics keep their own update loops, so a declarative scene doesn't require React to render every animation frame.

Three.js's [TSL shader language](https://github.com/mrdoob/three.js/wiki/Three.js-Shading-Language) takes a similar approach to shader logic traditionally written in GLSL. I used its composable javascript API for flame, grass, and grid experiments. TSL generates shaders for WebGPU or WebGL, so those effects can share the web's module system while still running on the GPU.

## Interface and animation

I used figma for the UI and SVG artwork, and rigged and animated the selection Pokémon in rive. Rive kept the animation editor, state machines, and runtime together. All three Pokémon share one **503 kB animation file**.

For the selection sparks, I tried lottie's React player with SVG rendering: **154 kB of JSON**. Both sizes exclude the player runtimes. motion animates the cards as they follow the pointer and handles the transition into battle.

Video: [Pokémon selection and battle transition](https://anishwij.com/media/blog/experiments/pokemon-playroom/interface-animation.mp4) — Animated Pokémon selection and the Poké Ball transition into battle.

I used blender to rig and animate the supplied 3D Pokémon models and assemble the scenes.

The interface stays in HTML. Pokémon cards are labelled buttons with selection states, battle messages use `aria-live`, and transitions respect reduced-motion preferences. Keeping that UI in the DOM preserves standard keyboard and focus behaviour and access to components from libraries such as <https://www.radix-ui.com/primitives/docs/overview/accessibility>

radix. A canvas shouldn't mean giving up those foundations.

## Camera and controls

Experiences like [Coastal World](https://mercimichel.medium.com/coastal-world-8f23b945823b) made me interested in moving through a 3D world with one thumb or a pointer. The camera needs to follow and turn with the player, so movement doesn't require a second hand just to manage the view.

[Ash third-person controller](https://labs.anishwij.com/unlisted/ash-third-person) — A character controller exploring one-thumb movement with an automatically following camera, alongside keyboard and pointer controls.

Use the Camera button to switch to Automatic follow, then try the controller on a phone. One thumb moves Ash while the camera follows his heading. Keyboard, pointer controls, and touch share movement logic built around Rapier physics.

The controller reads drei's Zustand-backed keyboard store inside `useFrame` and updates the physics body through `rigidBodyRef`. Input stays reactive without sending every movement through React state, following React Three Fiber's [guidance for frequent updates](https://r3f.docs.pmnd.rs/advanced/pitfalls).

## Effects and sequencing

Three.js's [chainable animation actions](https://threejs.org/docs/pages/AnimationAction.html) inspired my effects API. I made the chains awaitable so a sequence could wait for an effect to finish.

[R3F - VFX System](https://codesandbox.io/p/sandbox/r3f-vfx-system-t5kjyd) — An effects experiment exploring a chainable API for sequencing sprite animation, movement, and scaling.

This sandbox led to reusable `SpritePlane` and `SpriteStream` components for the battle. A sprite's ref exposes a chainable API; Charmander's Ember uses this to grow its charging flare:

```ts no-copy
await fireFlareRef.current
  ?.reset()
  .moveTo(0, 0.4, -1.5)
  .scaleTo(0.2, 0.2, 0.2)
  .to({ position: [0, 0.6, -1.6], scale: [2, 2, 2], duration: 0.7 })
```

Each method appends work to a Promise queue and returns the same handle. Its `then()` method delegates to that queue, which makes the whole chain awaitable. `useImperativeHandle` exposes the handle through the sprite's ref, so callers don't need to know how its tweens or sprite frames run.

## Scene coordination

The scene composer brings Pokémon, camera paths, and effects into a sequence of beats. Choose Director, then Play to watch the Pokémon actions and camera movements run together.

[Scene composer](https://labs.anishwij.com/unlisted/scene-choreography) — A sequencing experiment that coordinates Pokémon actions, camera paths, and visual effects into timed beats.

The composer uses legend-state to track completion across actors, camera, and VFX. Actions within a beat run together; the next beat waits for them.

Back in the battle, the same idea connects animation to game state. This excerpt moves the camera, overlaps the attack and effect, then applies damage and plays the opponent's reaction:

```ts no-copy
await commands.camera('player-attack')
await Promise.all([
  commands.animate('player', 'attack'),
  commands.effect(player.element, battlePositions.player, battlePositions.opponent),
])
const nextOpponentHealth = await commands.damage('opponent', PLAYER_DAMAGE)
await commands.animate('opponent', 'hit')
```

Damage updates React state, which flows back into the `health` prop on `WorldHealthBar`. Character clips run through Three.js's animation mixer; effects keep their own animation routines. The common command API coordinates both.

## React for games

Unity's browser builds connect their runtime to the surrounding page through [JavaScript integration](https://docs.unity3d.com/6000.0/Documentation/Manual/webgl-interactingwithbrowserscripting.html). In this multiplayer proof of concept, the scene shares React components and state with the page from the beginning. I want game state and persistence to use familiar web APIs too, through tools such as Convex or drizzle.

The data layer is the next piece to improve. My registry made it possible to find an object and call its methods. I'm exploring entities with traits such as position, health, and current action, and systems that query those traits directly. I want components to work with that data without custom wiring for every query.

I'd like to build a shared game framework around these patterns. An encounter could load its own Pokémon, effects, and rules as a module, reusing the accessible interface and controls. Developers could extend those pieces through familiar React components while keeping direct control over graphics and timing.

**Keep exploring**

[Read case study](https://anishwij.com/blog/wwjdfilm)

[Read case study](https://anishwij.com/blog/undp-survey)
