Because client work isn't the only reason to write code
We do this for a living — Laravel apps, product refactors, the usual grown-up stuff. That doesn't mean every line of code has to bill a client.
Galactic Strike is an in-house space shooter we built for the joy of it: Vite + TypeScript + Phaser, lasers, bosses, the works. You can play it here. It started as something fun on the side and turned into a proper excuse to keep our devs fresh — same stack muscles we use day to day, zero deadline pressure.
Doing serious work all the time is how skills go stale. A small game forces you to read unfamiliar APIs, refactor when things get messy, and actually run the build before you ship — without a stakeholder watching the burndown chart.

Same habits, different canvas
Even a silly shooter maps cleanly onto how we think about product code:
| Day job habit | Galactic Strike repo |
|---|---|
| Routes | Scene registration in src/main.ts |
| Controllers | Phaser scenes (GameScene, StartScene, …) |
| Models / entities | src/entities/ |
| Config | src/config.ts |
| Services | SpawnController.ts, CollisionService.ts |
| Public assets | public/assets/ |
It is the same discipline — thin scenes, extracted services, config you can tune without spelunking — just with more explosions than your average CRM.
The runnable app lives in our academy repo (forked from project-space-shooter) so anyone on the team — or anyone learning with us — can clone it, break it, and fix it.
Refactoring because we couldn't leave it ugly
Early GameScene code owned everything — spawn timers, waves, boss triggers, every overlap handler. Classic god controller. Even on a fun project, that gets annoying fast.
So we pulled the same move we use on client code:
SpawnController owns spawn timers, wave counter, boss spawn, and the boss reference. GameScene creates it in create() and calls update() each frame.
CollisionService owns overlap handlers — bullets, shields, ricochet, ship collisions. GameScene delegates to collisionService.update() after enemy updates.
Same lesson as avoiding controller bloat in Laravel. Different runtime. Still satisfying.

Staying sharp without burning out
The point isn't to become game developers. The point is to stay curious — read a real TypeScript repo, extract a service class, write a test that doesn't boot the whole UI.
Vitest covers pure game logic and services without booting Phaser — damage rules, spawn thresholds, config branches. Framework at the edges, logic you can actually test. That reflex carries straight back to client work on Monday morning.
cd php-to-js/16-space-shooter
npm install
npm run dev # local play
npm test # Vitest
npm run build # dist/ for static host
Output lands in dist/; assets under public/ copy into the build. Even side projects deserve a clean deploy.

Adding a new enemy (when you can't stop tinkering)
Once you're in, it's hard not to add one more thing. The README spells out a repeatable path:
- Create a class in
src/entities/enemies/extendingBaseEnemy. - Preload the texture in
LoadingScene.ts. - Add a spawn path in
SpawnController. - Handle score branches in
CollisionService. - Add a constant under
SCORE_CONFIGinconfig.ts. - Export from
src/entities/enemies/index.ts.
Entity, asset, spawn rule, collision outcome, config, barrel export — a vertical slice, testable at each step. Fun project; serious craft.
Play it, poke around, steal the pattern
We build things like this in-house because learning shouldn't only happen on someone else's invoice — and because blowing up pixel ships is a perfectly valid way to keep your edge.
If you want a studio that ships client work and knows how to stay sharp between projects, book a free systems review or see what we offer.