Trapped in a Cube: Why Cube Penance Runs on Excalibur
Let's get the premise out of the way first: you are a tiny ship, welded into a cube, and the cube is full of things that want to kill you. That's it. That's the game. We called it Cube Penance, because "Box of Regret" sounded too on-the-nose.
Mechanically, it's a top-down bullet-hell roguelike. You fly around a square arena, shoot enemies, try not to touch the walls (the walls are not your friends), and if you survive, you get Stars. Stars buy Upgrades. Upgrades make the next cube slightly less likely to kill you. There's a 4×4 grid of worlds, each containing a 4×4 grid of stages, because apparently one grid wasn't enough — we needed a grid of grids. Very on-brand for a game about cubes.
Why Excalibur?
When picking an engine, the requirements were: 2D, TypeScript-first, actor/scene model, built-in physics, and — critically — a name cool enough to put in a blog post. Excalibur.js ticked every box. It gives you Actor, Scene, Engine, sprite/graphics handling, input, audio, and a physics system with collision types, all without needing to glue together five different libraries before writing a single line of game logic.
And speaking of physics — yes, we went in assuming we'd eventually need to bolt on Matter.js for "real" collision handling, because that's what every tutorial on the internet does. Turns out: no. Excalibur ships its own physics engine, with Body, CollisionType (Active, Fixed, Passive, PreventCollision), and collision groups baked right in. We never imported Matter.js. Not once. The dependency we were bracing ourselves to "definitely need" simply... wasn't needed. A rare and beautiful moment of not over-engineering something, and we'd like to take full credit for it even though it was mostly luck.
The shape of the codebase
One decision that's already paying off: every gameplay system gets a pure-logic layer with zero dependency on Excalibur — plain TypeScript functions and state objects — and then a thin Actor wrapper that only handles rendering and lifecycle. Want to test "does the upgrade math work"? No engine, no canvas, no npm run dev, just a function call and an assertion. This split is going to come up a lot in future posts, mostly because it's the reason those posts could be written at all — bugs that would otherwise require manually flying a ship into a wall fifty times get caught by a test suite instead.
Speaking of bugs caught (and not caught) — next time: a multishot upgrade that fires bullets in a perfect circle, an enemy that keeps shooting after it's already dead, and an upgrade that flatly refuses to follow the rules everyone else agreed on. See you in the cube.