Try it live at 30days.abduarrahman.com — and the source code is on GitHub.
The Origin
Day 3 needed to be different from precision timers and math animations. The community wanted something visual, something chaotic, something that plays itself. Someone said "make them fight" — so I built a pixel art arena where characters bounce around and beat each other up.
The Granloop Arena TV is a self-contained fighting game in a draggable, collapsible TV widget that sits on the landing page. Two teams — Granmaja (orange) vs Bloop (purple) — fight in a 2v2 pixel art brawl until one team is eliminated.
What I Built
A complete 2v2 pixel art fighting game with:
- 4 pixel art characters — 2 Granmaja and 2 Bloop with sprite animations for walking in all 4 directions
- Real-time physics — characters bounce around the arena with velocity, wall reflections, and speed normalization
- Collision detection — when characters overlap, they bounce off each other with velocity exchange
- Cross-team damage — collisions between different teams deal random damage (50-3000 per hit)
- Individual HP bars — each character has their own health bar that changes color as HP drops
- KO system — when both members of a team reach 0 HP, the match ends with a winner screen
- Rematch button — instantly reset and start a new fight
- Draggable TV widget — the whole arena is a movable, collapsible window
- Synthesized sound effects — clash and KO sounds via Web Audio API
How It Works
Sprite Animation System
Characters use CSS-based sprite sheet animation. Each direction has a sprite sheet with multiple frames, and the game loop advances frames at 120ms intervals:
function bgPos(d: Direction, f: number, c: SpriteConfig): string {
if (d === "right" || d === "left") {
const tf = c.rightCols * c.rightRows;
const fr = f % tf;
const col = fr % c.rightCols;
const row = Math.floor(fr / c.rightCols);
return `${-(col * SZ)}px ${-(row * SZ)}px`;
}
return `0px ${-(f % c.upDownFrames) * SZ}px`;
}
Characters face left by flipping the right-facing sprite with scaleX(-1). The direction is determined by velocity:
const ax = Math.abs(ch.vx), ay = Math.abs(ch.vy);
ch.dir = ay > ax ? (ch.vy < 0 ? "up" : "down") : (ch.vx < 0 ? "left" : "right");
Collision Detection & Damage
Every frame, all character pairs are checked for collisions. When two characters from different teams overlap:
const colD = SZ * 0.65; // collision distance (65% of sprite size)
for (let i = 0; i < 4; i++) {
if (c[i].hp <= 0) continue;
for (let j = i + 1; j < 4; j++) {
if (c[j].hp <= 0) continue;
const dx = c[i].x - c[j].x, dy = c[i].y - c[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist >= colD || c[i].ct > 0 || c[j].ct > 0) continue;
// Bounce apart with velocity exchange
const overlap = colD - dist;
const px = (dx / dist) * overlap * 0.6;
c[i].x += px; c[j].x -= px;
const nx = dx / dist;
const dot = (c[i].vx - c[j].vx) * nx + (c[i].vy - c[j].vy) * ny;
c[i].vx -= dot * nx; c[j].vx += dot * nx;
// Cross-team damage only
if (TEAM[i] !== TEAM[j]) {
const di = rDmg(), dj = rDmg(); // 50-3000 random damage
c[i].hp = Math.max(0, c[i].hp - di);
c[j].hp = Math.max(0, c[j].hp - dj);
}
}
}
The ct (cooldown timer) prevents the same pair from hitting each other every frame — they need to separate first.
KO Detection
After each hit, the game checks if both members of either team are dead:
const t0alive = c[0].hp > 0 || c[1].hp > 0;
const t1alive = c[2].hp > 0 || c[3].hp > 0;
if (!t0alive || !t1alive) {
overR.current = true;
const w = !t0alive && !t1alive ? -1 : !t0alive ? 1 : 0;
setWinner(w);
playKO();
}
Draggable Widget
The entire TV is draggable by its header bar, using mouse/touch events:
const onDragStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
const cx = "touches" in e ? e.touches[0].clientX : e.clientX;
const cy = "touches" in e ? e.touches[0].clientY : e.clientY;
const ox = pos.x === -1 ? (rootRef.current?.offsetLeft ?? 16) : pos.x;
const oy = pos.y === -1 ? (rootRef.current?.offsetTop ?? window.innerHeight - (TV_H + 160)) : pos.y;
dragRef.current = { sx: cx, sy: cy, ox, oy };
}, [pos]);
Tech Stack
| Technology | Purpose |
|---|---|
| Next.js | React framework |
| TypeScript | Type-safe game logic |
| CSS Sprite Sheets | Pixel art character animation |
| requestAnimationFrame | 60fps game loop |
| Web Audio API | Clash and KO sound synthesis |
| React Refs | Direct DOM manipulation for performance |
Links
- Live Demo: 30days.abduarrahman.com
- Source Code: github.com/ab2rahman/30days-web-challenge
-
Key File:
GanBloop.tsx
Follow the challenge:
- Instagram: @abduarrahman
- YouTube: @abduarrahmanscode
- TikTok: @anduarrahmans
Support the challenge:
- Ko-fi: ko-fi.com/abduarrahman
Originally published at abduarrahman.com
Top comments (0)