I tried to make Pixi-React work for Panic Spiral. I felt like it would fit very well into a NextJS app, since NextJS is built to work with React. I think it could work fine for a repeating animation, or something with limited interactivity.
Unfortunately, Pixi-React just doesn’t seem to cope well when the player is constantly interacting by holding down keys on the keyboard and causing things to move around the screen in doing so. And that is one of the core mechanics of Panic Spiral.
So, sadly, I’ve had to mostly ditch Pixi-React and rewrite the whole thing using Typescript to interface directly with the PixiJS API. It wasn’t too difficult this early in the project, but was a bit time consuming. Ultimately, it was worth it though – after the rewrite, I was able to implement crisp, responsive movement, and am now in the process of adding collision detection.
This post is going to be a bit technical again – I’m going to explain what I’ve found out about Pixi-React that means it doesn’t work for Panic Spiral.
A brief look at the React lifecycle
If you want a more in-depth look at this, I suggest taking a look at the React documentation for preserving and resetting state.
An app made using React is built using components. For at least the last five years, the convention is to use function components – that is a function that returns a component.
// "Person" is a function that takes the firstName and lastName
// as arguments
const Person = ({firstName: string, lastName: string}) => {
// in this simple example, a HTML element is returned, which displays
// the firstName and lastName
return (<div>{firstName} {lastName}</div>);
}
When rendering your app, React creates a full virtual version of the page (the virtual DOM), keeping track of which HTML elements belong to which components.
Components can have state by using a special React function called a hook. In this case, we need the useState hook.
const Person = ({firstName: string, lastName: string, startingAge: number}) => {
// useState returns two outputs - the first is the current value of the
// state. The second is a function to change the state
const [age, setAge] = useState(startingAge);
// simple function to add 1 to the age
const addYear = () => {
setAge(age + 1);
};
// returns a HTML element that displays the person's details and a
// button that increases their age by 1 when pressed
return (
<>
<div>{firstName} {lastName}</div>
<div>{age} years old</div>
<button onClick={addYear}>Add Year</button>
</>
}
The useState hook tells React to persist the value outside of the function, since functions don’t normally have state.
When setAge is called, React detects that the Person component we’ve made here needs to be re-rendered. At this point, it rebuilds the entire virtual DOM and compares the difference between this new version and the old one. Then it analyses what’s changed and updates the real page with only the changes.
In general, this is very efficient, and updates are completed in milliseconds without users noticing.
Building a moving player character with React-Pixi
In Panic Spiral, I want the player to be able to control the character using the keyboard. This means detecting which buttons are being held down and setting character state based on that information.
Let’s start by detecting when the up button (using “w” as an example) is held down. By convention in software development, coordinates start at (0,0) in the top left corner of the browser with x increasing as you go down the page, and y increasing as you move right.
So we’ll start the player character at coordinates (50,50) and move them in the negative x direction when up is held down.
const PlayerCharacter = () => {
// setting up state to persist the coordinates and whether the button
// is held down
const [x, setX] = useState(50);
const [y, setY] = useState(50);
const [isUpHeld, setIsUpHeld] = useState(false);
const getAnimation = () => {
// if up button is held, get the up animation
if (isUpHeld) {
// assuming our up animation is defined elsewhere
return MoveUpAnimation;
}
// and providing an idle animation (also defined elsewhere) as
// a default if no button is held down
return IdleAnimation;
}
// return an animation component at the given coordinates that plays
// the given animation
return (
<Animation x={x} y={y} animation={getAnimation()} />
)
}
This component will currently render the player character at (50, 50) with an idle animation, but since we don’t actually update isUpHeld it will never change to the move up animation. There’s also no code to update the position.
Updating the position when move up is pressed
Let’s change that.
const PlayerCharacter = () => {
const [x, setX] = useState(50);
const [y, setY] = useState(50);
const [isUpHeld, setIsUpHeld] = useState(false);
// useEffect is a React Hook that runs when the component renders
const useEffect(() => {
// these lines ensure that this component can react to the keys that
// the player presses
// keydown fires when the player presses a new key
window.addEventListener("keydown", onKeyDown);
// keyup fires when the player releases a key
window.addEventListener("keyup", onKeyUp);
// this ensures that our keyup and keydown listeners are removed
// when the component is removed
return () => {
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
};
// the empty array here means this function is only run once when the
// component first renders
}, []);
// making a function to check whether the key pressed is our move up key
const onKeyDown = (event: KeyboardEvent) => {
// if it is the move up key, we update the state to reflect that
if (event.key === "w") {
setIsUpHeld(true);
}
};
// this function checks if the released key is the move up key
const onKeyUp = (event: KeyboardEvent) => {
// if it is the move up key, then up is no longer held, so we update
// the state to indicate that
if (event.key === "w") {
setIsUpHeld(false);
}
};
// this is a Pixi-React hook that runs any attached functions every
// frame
useTick(moveCharacter);
// this is our movement function. It uses another react hook -
// useCallback - so that the function doesn't get recreated and added
// every single frame
const moveCharacter = useCallback(() => {
// in this function, we check if the move up button is held
if (moveUpHeld) {
// if so, we move the character 1 pixel up the screen
setX(previousState => previousState - 1);
}
// this array here re-creates the function when moveUpHeld changes.
// if we didn't have this, the function would always use the first value
// of moveUpHeld (false) and so we'd never see movement
}, [moveUpHeld])
const getAnimation = () => {
if (isUpHeld) {
return MoveUpAnimation;
}
return IdleAnimation;
}
return (
<Animation x={x} y={y} animation={getAnimation()} />
)
}
That was a rapid increase in complexity.
The summary is that we:
- Added functions to update if the up key is pressed or not. These functions run when the player presses or releases a key and check if that key is our move up key (“w”). If it is, the component’s state is updated to reflect the change
- Added a movement function that gets run every frame. This function checks if the move up key is held and moves the character 1 pixel up the screen if it is
- Made sure these functions don’t run or update too often to prevent React rendering problems
Adding the other directions
For Panic Spiral, I want the player to be able to move their character in four directions – up the screen, down the screen, left, and right. So that means we need to extend the movement code so it can cope with four different inputs. Let’s say that we use “a” to move left, “s” to move down, and “d” to move right.
const PlayerCharacter = () => {
const [x, setX] = useState(50);
const [y, setY] = useState(50);
const [isUpHeld, setIsUpHeld] = useState(false);
const [isLeftHeld, setIsLeftHeld] = useState(false);
const [isDownHeld, setIsDownHeld] = useState(false);
const [isRightHeld, setIsRightHeld] = useState(false);
const useEffect(() => {
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
return () => {
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
};
}, []);
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "w") {
setIsUpHeld(true);
} else if (event.key === "a") {
setIsLeftHeld(true);
} else if (event.key === "s") {
setIsDownHeld(true);
} else if (event.key === "d") {
setIsRightHeld(true);
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === "w") {
setIsUpHeld(false);
} else if (event.key === "a") {
setIsLeftHeld(false);
} else if (event.key === "s") {
setIsDownHeld(false);
} else if (event.key === "d") {
setIsRightHeld(false);
}
};
useTick(moveCharacter);
const moveCharacter = useCallback(() => {
if (moveUpHeld) {
setX(previousState => previousState - 1);
} else if (moveDownHeld) {
setX(previousState => previousState + 1);
}
if (moveLeftHeld) {
setY(previousState => previousState - 1);
} else if (moveRightHeld) {
setY(previousState => previousState + 1);
}
}, [moveUpHeld, moveLeftHeld, moveDownHeld, moveRightHeld])
const getAnimation = () => {
if (isUpHeld) {
return MoveUpAnimation;
}
if (isLeftHeld) {
return MoveLeftAnimation;
}
if (isDownHeld) {
return MoveDownAnimation;
}
if (isRightHeld) {
return MoveRightAnimation;
}
return IdleAnimation;
}
return (
<Animation x={x} y={y} animation={getAnimation()} />
)
}
There’s not a lot of complexity added here. We’ve added a couple of things:
- Checking whether the “a”, “s”, and “d” keys are held down
- A slight priority system for movement – we favour moving up over down and left over right, but can have as many keys as we want pressed down
- A priority system for the animation – the order is Up, Left, Down, Right
From here, we can refactor so that the code is tidier and we remove repetition.
And now we have problems
Unfortunately, this is where I started to see problems with moving and animating the player character.
The animation is also a react component, and that too was updating when the animation frame swapped. I regularly noticed that the animation sometimes didn’t update for the new player character direction. This did have some fun interactions, such as allowing the character to moonwalk, which my three-year-old daughter found hilarious.
However, this fun bug was also coupled with a more serious one. Sometimes the movement direction wouldn’t match the buttons that were pressed – e.g. I’d press up, hold it down, then press left before releasing the up key and sometimes the character would keep moving up for a second, or just stop completely.
On top of that, the animation and movement felt very jittery. Technically the character was moving at a constant speed. But they would often appear to stop for half a second and then jump forward a bit. This was not a great player experience.
I spent several days trying to tweak things and optimise my code, with each iteration fixing some things while making others worse.
Eventually, I discovered a tutorial about making 8-directional movement using keyboard inputs in a PixiJS. This one didn’t have any of the problems I was seeing. The difference? They weren’t using Pixi-React.
Finally, a solution
And so I came to the conclusion that I needed to rip out everything I’d done and rebuild it using the PixiJS API rather than Pixi-React.
Fortunately, I happened across a blog post where someone else seemed to have come to the same conclusion.
I’ve added in some of my own code to initialise assets and audio players, but otherwise my code is very similar.
GameWrapper component
First, I have a wrapper component that deals with all the initialisation. This makes sure that all the assets are loaded, the SFX/BGM players have been created, and the KeyboardEventHandler has been set up to accept new listeners. Finally, it creates a React-Pixi application wrapper so that this can be integrated into my NextJS app easily.
// extend tells @pixi/react what Pixi.js components are available
extend({
Container,
Graphics,
Sprite,
Text,
AnimatedSprite,
TilingSprite,
});
interface IGameProps {
parentRef: HTMLElement | Window | RefObject<HTMLElement | null> | undefined;
}
const GameWrapper = ({ parentRef }: IGameProps) => {
const [assetsLoaded, setAssetsLoaded] = useState(false);
const [audioInitComplete, setAudioInitComplete] = useState(false);
const [inputListenerSetup, setInputListenerSetup] = useState(false);
useEffect(() => {
if (!assetsLoaded) {
initAssets().then(() => {
setAssetsLoaded(true);
});
}
if (!audioInitComplete) {
GameAudio.SFX = new SFXPlayer();
GameAudio.BGM = new BGMPlayer();
setAudioInitComplete(true);
}
if (!inputListenerSetup) {
Inputs.Keyboard = new KeyboardEventHandler();
setInputListenerSetup(true);
}
}, [assetsLoaded, audioInitComplete, inputListenerSetup]);
return (
// wrapping in application provides the pixijs app context
assetsLoaded && (
<Application
resizeTo={parentRef}
defaultTextStyle={{ fontFamily: "Reconstruct", fill: "#ffffff" }}
>
<Game />
</Application>
)
);
};
Due to the problems with Pixi Sound I mentioned in my last post, this wrapper has to be exported in a NextJS dynamic function.
const GameWrapper = dynamic(() => import("./GameWrapper"), { ssr: false });
Game component
The next component down is one that grabs the application context from Pixi-React and passes it to the PixiJS code so that the rest of the game can use it.
const Game = () => {
let { app } = useApplication();
useEffect(() => {
if (!app.ticker) {
return;
}
app.stage.removeChildren();
runGame(app);
}, [app, app.ticker]);
return <></>;
};
This code has to check that the app has actually been created (specifically that the ticker is defined), before calling the runGame function. This component is completely empty otherwise.
runGame function
And here we are. This is the function that, as you might have guessed from the name, runs the game code.
const runGame = (app: Application) => {
screenManager.init(app);
screenManager.resize(app.renderer.width, app.renderer.height);
screenManager.changeScreen(TitleScreen);
};
Alright, it actually passes everything on to a screenManager object, which then handles adding everything to the app stage. The important code for that is here:
class ScreenManager {
public screenView = new Container();
private _app!: Application;
public init(app: Application) {
app.stage.addChild(this.screenView);
this._app = app;
}
public async changeScreen(Ctor: GameScreenConstructor) {
// takes a game screen constructor and uses it to create an instance
// of that screen before adding it to the screenView
}
}
const screenManager = new ScreenManager();
There’s a lot of code within the ScreenManager which deals with checking assets are loaded, removing the previous screen, and then initialising the new screen. I might take a deep dive look into that in a future post.
For now, though, I think there’s been enough code.
And the result…

After the rewrite, the player character moves smoothly around the screen. There’s no funny behaviour where movement keys changes aren’t registered properly. I’ve restricted movement to just the four directions since I don’t have animations for moving diagonally and so that movement looked weird.
Was it annoying to have to rewrite all I’d done so far?
Yes.
Was it worth it?
Absolutely.