DEV Community

Cover image for Granloop Arena TV — Pixel Art Fighting Game — 30 Days Web Challenge Day 3
Muhammad Abdu ar Rahman
Muhammad Abdu ar Rahman

Posted on • Originally published at abduarrahman.com

Granloop Arena TV — Pixel Art Fighting Game — 30 Days Web Challenge Day 3

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`;
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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

Follow the challenge:

Support the challenge:


Originally published at abduarrahman.com

Top comments (0)