Building Pixel Swarm as a Browser RTS Sandbox
Pixel Swarm started as a browser RTS, but the repo grew into something a little more interesting than a simple skirmish toy. It now combines a deterministic simulation core, a custom canvas renderer, a persistence layer backed by sql.js, a testing lab for combat behavior, and a VFX workflow for authoring attack patterns and stress-testing them at scale.
That combination is the reason I like this repo. It is not just “units moving around on a map.” It is an attempt to build a compact strategy sandbox where simulation, debugging, content tooling, and rendering all live in one coherent front-end codebase.
What I Built
At a high level, the project breaks down into five pieces:
- A React shell in
App.tsxthat owns the UI, tools, modal flows, and game configuration. - A
GameEngineservice that runs the actual simulation. - A
GameCanvasrenderer that handles camera movement, selection, map caching, and draw-time effects. - A
PersistenceServicethat writes commands, events, snapshots, and map data into an in-browser SQLite database. - Tooling components like
TestingLab,VfxForgeModal, andStressAttackBakerfor authoring and validating combat content.
That architecture is deliberate. I wanted the simulation to be first-class, not hidden inside a React component tree.
The Core Design Choice: Keep the Engine Separate
The most important architectural decision in this repo is that the game engine is its own service instead of being embedded inside component state.
React owns the shell:
- menus
- build actions
- testing controls
- debugging panels
- modal flows
The simulation lives in GameEngine.
That separation keeps the frame loop, unit systems, command handling, and pathfinding logic out of the UI layer. It also makes testing much easier because I can instantiate the engine directly without mounting the whole app.
The app wiring is simple:
const engine = useMemo(() => new GameEngine(), []);
useEffect(() => {
let cancelled = false;
const persistence = new PersistenceService();
persistence.init().then(() => {
if (!cancelled) engine.attachPersistence(persistence);
});
return () => {
cancelled = true;
engine.attachPersistence(null);
};
}, [engine]);
That is the kind of boundary I want in a game codebase. The UI can change. The simulation contract stays stable.
Deterministic Simulation and Lockstep Thinking
The GameEngine is not just updating entities opportunistically every frame. It is built around a fixed-step simulation loop with command scheduling, checksums, and periodic snapshots.
step(deltaTime: number) {
this.accumulator += deltaTime;
while (this.accumulator >= SIM_STEP_MS) {
this.tick();
this.accumulator -= SIM_STEP_MS;
}
}
private tick() {
if (this.gameState !== GameState.PLAYING) return;
this.tickCount++;
this.currentPathOps = 0;
for (const u of this.units) { u.prevX = u.x; u.prevY = u.y; }
for (const p of this.projectiles) { p.prevX = p.x; p.prevY = p.y; }
this.processCommands();
this.updateSpatialHash();
this.updateWeather();
this.updateTacticalPowers();
this.updateBuildings();
this.updateUnits();
this.updateProjectiles();
this.updateAttacks();
this.processFlowFieldRequests();
if (this.tickCount % CHECKSUM_INTERVAL === 0) {
this.currentChecksum = this.calculateChecksum();
}
if (this.tickCount % SNAPSHOT_INTERVAL === 0) {
this.recordSnapshot();
}
}
That loop says a lot about what I was optimizing for:
- predictable updates
- replayable state
- testable systems
- a path toward lockstep-style validation
Even though this is still a browser game, I wanted the simulation to behave like an engine, not like a collection of React timers.
Commands, Snapshots, and Persistence
One of my favorite parts of the repo is the persistence layer. Instead of treating save state as a single JSON dump, I modeled it more like a runtime ledger:
- command log
- event log
- snapshots
- map definitions
The implementation uses sql.js, which gives me a tiny SQLite database in the browser:
logCommand(cmd: GameCommand) {
if (!this.db || cmd.tick === undefined || cmd.seq === undefined) return;
const payload = JSON.stringify(cmd.payload || {});
this.db.run(
'INSERT OR REPLACE INTO command_log (tick, player_id, seq, type, payload) VALUES (?, ?, ?, ?, ?)',
[cmd.tick, cmd.playerId, cmd.seq, cmd.type, payload]
); this.schedulePersist(); } logSnapshot(tick: number, checksum: number, state: string) { if (!this.db) return; this.db.run( ‘INSERT OR REPLACE INTO snapshots (tick, checksum, state) VALUES (?, ?, ?)’,
[tick, checksum, state]
); this.schedulePersist(); }
That is more infrastructure than a casual browser prototype needs, but it pays off. Once commands and snapshots are first-class data, debugging weird RTS behavior gets much easier.
Pathfinding as a Dedicated System
RTS games get ugly fast if movement is an afterthought. In this repo I used flow fields as a dedicated pathfinding layer rather than trying to improvise target steering on every unit independently.
The FlowField class computes a cost field with BFS, then derives direction vectors from the gradient:
generate(map: MapCell[][], tx: number, ty: number, domain: MovementDomain) {
this.target = { x: tx, y: ty };
for (let y = 0; y < MAP_HEIGHT; y++) {
for (let x = 0; x < MAP_WIDTH; x++) {
const node = this.nodes[y][x];
node.cost = 65535;
node.vectorX = 0;
node.vectorY = 0;
}
}
this.nodes[ty][tx].cost = 0;
queue.push((ty << 16) | tx);
while (head < queue.length) {
const packed = queue[head++];
const cy = packed >> 16;
const cx = packed & 0xFFFF;
const currentCost = this.nodes[cy][cx].cost;
for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) {
const nx = cx + dx;
const ny = cy + dy;
if (nx >= 0 && nx < MAP_WIDTH && ny >= 0 && ny < MAP_HEIGHT) {
if (isPassable(nx, ny) && this.nodes[ny][nx].cost === 65535) {
this.nodes[ny][nx].cost = currentCost + 1;
queue.push((ny << 16) | nx);
}
}
}
}
}
This is the right tradeoff for large groups. A field-based approach gives the swarm coherent movement without paying the full cost of running a separate expensive search for every single unit.
Rendering: Canvas, Caching, and Practical Performance
The rendering layer is also intentionally separate from the simulation. GameCanvas is responsible for:
- camera controls
- drag selection
- control groups
- build placement
- cached map rendering
- VFX sprite playback
- performance-aware visual degradation
One detail I like is that the renderer adapts VFX quality dynamically instead of pretending every machine can draw the same number of effects:
const updateVfxQuality = (frameMs: number, now: number) => {
const state = vfxQualityRef.current;
if (now - vfxLastAdjustRef.current < 350) return;
if (frameMs > 33) {
state.maxDraw = Math.max(60, Math.floor(state.maxDraw * 0.85));
state.scale = Math.max(0.35, state.scale - 0.05);
} else if (frameMs < 20) {
state.maxDraw = Math.min(450, state.maxDraw + 20);
state.scale = Math.min(0.65, state.scale + 0.02);
}
vfxLastAdjustRef.current = now;
};
That is a pragmatic systems choice. I would rather degrade gracefully than let a stress attack turn the whole game into a slideshow.
Tooling Inside the Game
Another thing I wanted here was internal tooling, not just gameplay UI.
The repo includes:
TestingLabfor validating unit, building, and ability behavior in isolationVfxForgeModalfor attack/VFX authoringStressAttackBakerfor generating baked patterns and pushing the renderer
The presence of those tools changes the repo from “an RTS demo” into “a game sandbox with internal development surfaces.” That matters because strategy games are hard to tune if every test requires booting a full match and hoping the exact situation happens naturally.
Testing the Systems
The automated tests reinforce that same design philosophy. The tests are mostly system-level, not component-snapshot fluff. For example, gameEngineSystems.test.ts checks economy behavior, fog of war, and combat log limits directly against the engine:
it('computes economy from extractor and fusion core counts', () => {
const engine = new GameEngine({ skipInitialSpawns: true, initialUnitsPerTeam: 0 });
(engine as any).adjustExtractorCount(0, 2);
(engine as any).adjustFusionCoreCount(0, 1);
const expected = 2 * EXTRACTOR_RATE * 30 + 1 * FUSION_CORE_RATE * 30 + BASE_ENERGY_REGEN * 30;
expect(engine.updateEconomy(0)).toBeCloseTo(expected);
});
That is exactly the kind of test I want in a simulation-heavy project. It tells me whether the rules are holding up, not whether a button happened to render.
Why I Like This Repo
What I like about Pixel Swarm is that it has a clear engineering center of gravity:
- simulation first
- UI second
- tooling built into the product
- persistence treated like structured data
- rendering optimized around practical constraints
It is still a browser game, but it is built with the mentality I prefer for game systems work. The code is not trying to be fashionable. It is trying to be useful: deterministic where it matters, inspectable where it gets complicated, and flexible enough to support both actual gameplay and the tooling needed to evolve that gameplay.
check it out here:
https://www.youtube.com/watch?v=ClXEMXj1ZKc