Skip to content
· 5 min read · 0 views

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

A pixel art 2v2 fighting game running inside a draggable TV overlay. Four characters — two Granmaja vs two Bloop — bounce around an arena, deal random damage on collision, and fight until one team is KO'd.

// table of contents (10 sections)

Try it live at 30days.abduarrahman.com and the source code is on GitHub.


Live Demo

The Granloop Arena TV floats on the landing page — expand it to watch the pixel art battle unfold:


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

TechnologyPurpose
Next.jsReact framework
TypeScriptType-safe game logic
CSS Sprite SheetsPixel art character animation
requestAnimationFrame60fps game loop
Web Audio APIClash and KO sound synthesis
React RefsDirect DOM manipulation for performance

Follow the challenge:

Support the challenge:

You might also like

Enjoyed This Post?

Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.

Discussion