It’s been a busy two weeks, although, unfortunately, very little of it was spent on game dev. I’ve had multiple job interviews, and then participated in a puzzle hunt (Glyph) with my friends over the weekend. It’s also been horribly warm, so my sleep schedule suffered greatly. Despite these things, however, I have managed to create the basic ship map in Panic Spiral!
But it wasn’t the simple process I thought it would be.
PixiJS, while very powerful for rendering and animating, is not as full featured as a game engine like Godot. It doesn’t include things like collision boxes, or tile grids as standard. While a tile grid isn’t essential, collision boxes are. Without collision logic, how would we know when the player character has hit a wall? And collision logic can be expanded to check if the player is near something they can interact with.
So today’s post focuses on my quest to implement collision logic using PixiJS.
Implementing collisions
What is collision logic?
As you might expect, this is something we experience every day in our day-to-day lives. When we sit down in a chair, we collide with the chair and are prevented from falling through it. When we open a door, our hands collide with the door handle and we can push or pull on the door. Put simply – collisions are what happens when two objects try to exist in the same space.
However, this isn’t really default behaviour for objects displayed on a computer screen.
If you have multiple windows open, you can move them over each other without them colliding. If you open a program like Paint and draw two boxes, there’s no problem with putting one on top of the other. And when you write a program that moves boxes around on a screen, it, by default, doesn’t prevent those boxes from moving on top of each other.


In order to make objects collide, we need to implement two things.
- We need to detect whether the objects overlap
- We need to tell the code what to do when two (or more) objects do overlap
Detecting overlapping objects
Panic Spiral is a fairly simple game. I don’t have extremely detailed character models, and I’m making things easy for myself by having everything fit on a grid. So walls are always a square, and the player character can be abstracted away as a rectangle. This means that collision logic can be done very easily using multiple axis-aligned bounding boxes.
Basically, I can make everything into a rectangle and just check if the two rectangles overlap.

You can replace the rectangles with other simple shapes too. For example, the maths for checking whether a circle and a rectangle overlap is not much more complex than checking if two rectangles overlap. The logic can also be extended into 3D games, but that’s beyond the scope of Panic Spiral.
The code for this with PixiJS is relatively straightforward.
In the following example, the (x,y) coordinate for each object is the top left corner of the box
function isOverlapping(objA: Container, objB: Container) {
// gets the AABB boxes for both objects
const boundsA = objA.getBounds();
const boundsB = objB.getBounds();
// checks if...
return (
// either box overlaps in the x direction
boundsA.x < boundsB.x + boundsB.width &&
boundsA.x + boundsA.width > boundsB.x &&
// and if either box overlaps in the y direction
boundsA.y < boundsB.y + boundsB.height &&
boundsA.y + boundsA.height > boundsB.y
);
// returns true if any part of one box is within the other box
}
Checking if the player overlaps a wall
For Panic Spiral, I decided to create a CollisonZone object that can be added to any object.
export class CollisionZone {
// the parent object can define its collision box
// having enabled as a boolean means collisions can be disabled later
constructor(public enabled: boolean, private _collisionBox: Container) { }
// allow other objects to get the bounds of the collision box
public getBounds() {
return this._collisionBox.getBounds();
}
}
// since I'm using a tile grid, walls are a type of tile
export class Tile extends Container {
// collisionZone is nullable - floor tiles do not want a collision,
// while walls do
public collisionZone?: CollisionZone;
// when the tile is created, define whether it is collidable
constructor(x: number, y: number, collidable = false) {
// general setup stuff
// add a collision zone if it is collidable
if (collidable) {
// for walls, the collision box is the same as the whole wall
// this may not be the case for decoration tiles
this.collisionZone = new CollisionZone(true, this);
}
}
}
The overlap logic then lives in a NavigationService class that the PlayerCharacter object can use to check if there’s been a collision.
class NavigationService {
private _collidableObjects: {collisionZone: CollisionZone}[];
constructor(map: Tile[]) {
// collidable objects are tiles with a collisionZone
// In javascript we can take advantage of a concept called
// "truthiness" to check this
this._collidableObjects = map.filter(t => !!t.collisionZone);
}
public collides(bounds: Bounds) {
return this._collidableObjects
// filter our collidable objects by whether collision is enabled
.filter((obj) => obj.collisionZone.enabled)
// then check if any have AABB overlap with the given bounds
.some((obj) => this.isOverlapping(bounds, obj.getBounds()));
}
private isOverlapping(boundsA: Bounds, boundsB: Bounds) => {
return (
boundsA.x < boundsB.x + boundsB.width &&
boundsA.x + boundsA.width > boundsB.x &&
boundsA.y < boundsB.y + boundsB.height &&
boundsA.y + boundsA.height > boundsB.y
);
})
}
Since the player character is the only object that moves, we can add the trigger to check for collisions to the player character object.
class PlayerCharacter extends Container {
private _collisionBox: Container;
// boolean for whether the player's moving or not
private _isMoving: boolean = false;
constructor(private _navigationService: NavigationService) {
// create a transparent rectangle for the collision box
this._collisionBox = new Graphics()
.rect(0, 8, 4, 4)
.stroke("#00000000); // alpha channel 0 makes it transparent
// add the collision box as a child so it moves with the player
this.addChild(this._collisionBox);
}
// update function that gets run every frame
public update(ticker: Ticker) {
// only check for collisions if the player's moving
if (this._isMoving) {
// check if there's a collision
if (this._navigationService.collides(
this._collisionBox.getBounds())) {
// do something on collision
}
}
}
}
Some of this logic is abstracted away into moving state code, but this is the basic premise of how it works.
Preventing the player character from moving into a wall
The logic described above just checks if the player’s collision zone overlaps another collision zone. Now we actually have to do something with that information to stop the player character moving into the wall.
I tried a couple of things and eventually settled on the following algorithm:
- Calculate the player’s new position when they’re moving
- Use that new position to create a projected set of bounds
- Check if the projected bounds collide with a wall
- If not, then move the player character into that new position
- Otherwise, don’t move the player character into the new position
The code for this is fairly straightforward.
class PlayerCharacter extends Container {
private _collisionBox: Container;
private _isMoving: boolean = false;
constructor(private _navigationService: NavigationService) {
// same as before. Code omitted here for brevity
}
public update(ticker: Ticker) {
if (this._isMoving) {
// get the new (x,y) coordinate
const [newX, newY] =
this.updatePosition(this.x, this.y, ticker.deltaMS);
// get the projected bounds and check that it DOESN'T collide
if (!this._navigationService.collides(
this.getProjectedBounds(newX, newY))) {
// if it doesn't collide, we can safely move the player character
this.x = newX;
this.y = newY;
}
}
}
private updatePosition(x: number, y: number, deltaMS: number) {
// example here if the player is moving left
// reduces the player y coordinate by a modifer multiplied by the
// number of milliseconds that have elapsed
return [x, y - PLAYER_MOVEMENT.MOVEMENT_SPEED_MODIFIER * deltaMS];
}
private getProjectedBounds(newX: number, newY: number) {
// gets the change in x and y
const [xDiff, yDiff] = [
newX - this.x,
newY - this.y
];
// get the actual bounds of the collision box
const bounds = this._collisionBox.getBounds();
// return a new bounds object with the change added on
return new Bounds(
bounds.minX + xDiff,
bounds.maxX + xDiff,
bounds.minY + yDiff,
bounds.maxY + yDiff
};
}
This works fine since there aren’t multiple moving objects. If there were multiple moving objects, then the logic for collisions would need to be handled in a different way. My thought would be that each object would have a function that could be called when a collision is detected, and that function could be used to return an object to its previous position, or perform some other action depending on what other object it has collided with.
I took a short video to demonstrate my collision logic and posted it on Bluesky a little while ago.
I had a lot on this week so didn't do much game dev. My main accomplishment is implementing collision boxes for Panic Spiral. Collision logic doesn't come as standard in PixiJS. It's amazing what little things I've started to miss from Godot. Here's a little tech demo! #gamedev #indiedev #pixijs
— Khemi (@khemitron-industries.net) July 14, 2025 at 6:14 AM
[image or embed]
Closing words
With collisions implemented, I’ve moved on to making the ship map in the game. My next post will go into the details of how I added that – spoiler alert: I had to create my own tile grid the logic for adding tiles to it.
Overall, I’m finding PixiJS quite fun to work with, even if it is lacking features that the big game engines have as standard. PixiJS itself is excellent for animations and I’m finding it very interesting to implement systems that I’ve taken for granted so far. It’s helping to give me a greater appreciation of how things are put together, and the challenge is something that I love.
I’m now at a point where I should be able to spend more time on game development again, so I’m hoping that I’ll be able to progress faster with Panic Spiral and have a working demo soon!