DEV Community

Cover image for I Built a Malaysian Cultural RPG in a Single HTML File — Here's What Broke
YT Low
YT Low

Posted on • Originally published at onlywebpro.com

I Built a Malaysian Cultural RPG in a Single HTML File — Here's What Broke

This is a cross-post. Original article on onlyWebPro.

Budak Kampung is a browser-based, top-down RPG set across a 1440×1440 world map with Malay, Chinese, and Indian cultural zones — built entirely in one self-contained HTML file. No engine, no npm, no build step.

This is the honest devlog: every decision I made, and every bug that nearly broke it.


Why build this at all

I'd already built AstroHop — a vertical space platformer — as a single-file HTML game, mostly as a challenge. That worked out well enough that I wanted to push further, this time with something culturally personal: a top-down RPG set in Malaysia.

The single-file constraint matters to me. It means the game is genuinely portable — drag it to your desktop, email it, open it in any browser on any device. No CDN dependencies that disappear, no broken npm installs. Just one file.


The concept: a One Malaysia RPG in the browser

Malaysia is unusually rich material for an RPG world because the culture is genuinely multicultural — Malay, Chinese, and Indian communities each with distinct food, architecture, festivals, and traditions. A game that leans into that rather than a generic fantasy setting felt worth making.

The scope: a top-down 2D RPG with a single continuous world, three cultural zones, enterable buildings, NPC dialogue, a persistent save system, and mobile-first controls. All in one HTML file.

Feature Details
🗺️ 1440×1440 World One continuous map, no loading screens
🏘️ 3 Cultural Zones Kampung Melayu, Pekan Cina, Little India
🚪 Enterable Buildings Kopitiam, surau, kovil — each with their own interior
💾 localStorage Save Position, visited buildings, dialogue flags
📱 Mobile-First Zone-based d-pad, HUD that doesn't eat your taps
🎨 Vector NPCs Hand-drawn canvas paths, no emoji, no sprites

Designing the world map

The world is 1440×1440 pixels of logical canvas space. Large enough to feel explorable, small enough to hand-code coordinates. The camera follows the player and clamps to world edges — only tiles within the camera view rectangle are drawn each frame. Basic culling, necessary from day one.

function updateCamera() {
  cam.x = player.x - canvas.width  / 2;
  cam.y = player.y - canvas.height / 2;

  // Clamp so camera never goes outside world bounds
  cam.x = Math.max(0, Math.min(cam.x, WORLD_W - canvas.width));
  cam.y = Math.max(0, Math.min(cam.y, WORLD_H - canvas.height));
}
Enter fullscreen mode Exit fullscreen mode

I got this wrong early: I used CSS canvas dimensions instead of actual pixel dimensions, which caused the camera to drift on HiDPI displays. That fed directly into Bug #1.


Bug #1 — The blurry canvas (HiDPI hell)

Symptom: Sharp on a 1080p monitor, visibly blurry on any Retina display or modern phone. Tap coordinates were also wrong — tapping a door triggered an interaction zone two tiles away.

The browser has two separate size systems for a canvas: the CSS display size and the actual drawing buffer size. On a Retina display, devicePixelRatio is 2, meaning one CSS pixel maps to a 2×2 block of real screen pixels. Draw at CSS size without accounting for this and the browser upscales — blurry.

function setupCanvas() {
  const dpr = window.devicePixelRatio || 1;

  const cssW = window.innerWidth;
  const cssH = window.innerHeight;

  // Drawing buffer — scaled up by dpr
  canvas.width  = cssW * dpr;
  canvas.height = cssH * dpr;

  // Visual CSS size stays the same
  canvas.style.width  = cssW + 'px';
  canvas.style.height = cssH + 'px';

  // Scale context so all draw calls use CSS pixel coordinates
  ctx.scale(dpr, dpr);

  viewport.w = cssW;
  viewport.h = cssH;
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Critical: After ctx.scale(dpr, dpr), use CSS pixel coordinates everywhere — player position, collision, tap detection. Mix raw pixels and CSS pixels anywhere in the game loop and your collision zones go wrong.


Bug #2 — Emoji rendering killed the game

Symptom: Windows showed monochrome glyphs. Some Android phones showed boxes. iOS showed the right size but misaligned. 🌴 and 🏮 looked wildly different across Chrome Mac, Firefox Windows, and Safari iPhone.

I'd been drawing NPCs with ctx.fillText() emoji. A Malay grandma is just 👵, right? The problem: emoji rendering in canvas is completely platform-dependent. The browser uses the OS emoji font. Every OS has a different one — different size, baseline, colour rendering, fallback behaviour. No way to normalize this across platforms.

The fix was throwing out every emoji and drawing vector characters with canvas paths instead.

function drawCharacter(ctx, x, y, opts = {}) {
  const {
    skinTone = '#c68642',
    shirtColor = '#3ecfff',
    pantColor = '#2d3a5e',
    accessory = null,   // 'songkok' | 'kebaya-headscarf' | 'bindi'
    scale = 1
  } = opts;

  ctx.save();
  ctx.translate(x, y);
  ctx.scale(scale, scale);

  // Legs
  ctx.fillStyle = pantColor;
  ctx.fillRect(-7, -20, 6, 20);
  ctx.fillRect(1, -20, 6, 20);

  // Body
  ctx.fillStyle = shirtColor;
  ctx.fillRect(-9, -40, 18, 22);

  // Head
  ctx.fillStyle = skinTone;
  ctx.beginPath();
  ctx.arc(0, -52, 12, 0, Math.PI * 2);
  ctx.fill();

  if (accessory === 'songkok') drawSongkok(ctx);
  if (accessory === 'bindi')   drawBindi(ctx);

  ctx.restore();
}

function drawSongkok(ctx) {
  ctx.fillStyle = '#1a1a2e';
  ctx.fillRect(-13, -67, 26, 8);  // brim
  ctx.fillRect(-10, -78, 20, 12); // crown
}
Enter fullscreen mode Exit fullscreen mode

More work, but the upside is real: characters look identical on every device, you control every pixel, and adding a new cultural accessory is a few canvas draw calls — not a new sprite sheet.


NPC dialogue and enterable buildings

Each NPC has an id, a position, and an array of dialogue strings. Within interaction range + action button → dialogue state flag flips → box renders over the canvas. Simple.

Buildings were more interesting. An enterable building is a zone rectangle in world space. Walk into it → switch to an interior scene with its own tilemap, furniture, and NPCs. Interiors render exactly the same way as the world — just a different data set.

const BUILDINGS = [
  {
    id: 'kopitiam',
    label: 'Old Town Kopitiam',
    worldZone: { x: 820, y: 340, w: 48, h: 12 },
    entryPos:  { x: 200, y: 320 },
    exitPos:   { x: 820, y: 360 },
    scene: 'interior-kopitiam'
  },
];

function checkBuildingEntry() {
  for (const b of BUILDINGS) {
    if (overlaps(player, b.worldZone)) {
      activeScene     = b.scene;
      player.x        = b.entryPos.x;
      player.y        = b.entryPos.y;
      currentBuilding = b;
      return;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The save system with localStorage

Small state object serialised to localStorage. Player position, active scene, visited building IDs, completed dialogue flags. On load, restore or start fresh.

const SAVE_KEY = 'budakkampung_save';

function saveGame() {
  const state = {
    playerX: player.x,
    playerY: player.y,
    scene:   activeScene,
    visitedBuildings:  [...visitedBuildings],
    completedDialogue: [...completedDialogue],
    savedAt: Date.now()
  };
  localStorage.setItem(SAVE_KEY, JSON.stringify(state));
}

function loadGame() {
  const raw = localStorage.getItem(SAVE_KEY);
  if (!raw) return false;

  try {
    const s = JSON.parse(raw);
    player.x          = s.playerX;
    player.y          = s.playerY;
    activeScene       = s.scene;
    visitedBuildings  = new Set(s.visitedBuildings);
    completedDialogue = new Set(s.completedDialogue);
    return true;
  } catch (e) {
    localStorage.removeItem(SAVE_KEY);
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

The try/catch around JSON.parse() is not optional. localStorage can return corrupted data — full storage, manual edits, format changes between game versions. Crashing on load is unforgivable. Silently starting a new game is always the right fallback.


Bug #3 — The player got permanently stuck

Symptom: Player saves inside a building, quits, reloads. Correct interior position restored — but the exit door does nothing. The only escape: delete the save.

Root cause: one function handled both entering and exiting buildings. Exit detection checked against world-space entry zones — meaningless inside an interior.

Fix: split into two functions, each only called in the right scene context.

const INTERIOR_EXITS = {
  'interior-kopitiam': { x: 180, y: 360, w: 60, h: 20 },
};

function gameTick() {
  if (activeScene === 'world') {
    checkBuildingEntry();
  } else {
    checkBuildingExit();
  }
}

function checkBuildingExit() {
  const exitZone = INTERIOR_EXITS[activeScene];
  if (!exitZone) return;

  if (overlaps(player, exitZone)) {
    player.x        = currentBuilding.exitPos.x;
    player.y        = currentBuilding.exitPos.y;
    activeScene     = 'world';
    currentBuilding = null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Bug #4 — The HUD was eating all my clicks

Symptom: Save button works fine. Tapping the lower game world does nothing — even though the HUD buttons aren't visually there. The game feels broken in the bottom third.

The HUD is an absolutely-positioned <div> over the canvas. It spans full screen height, but the buttons only sit in the corners. The transparent container was intercepting all pointer events in its area.

#hud {
  position: absolute;
  inset: 0;
  z-index: 10;
  pointer-events: none; /* container is click-through */
}

/* Re-enable only on actual interactive children */
#hud button,
#hud .dpad-zone {
  pointer-events: auto;
}
Enter fullscreen mode Exit fullscreen mode

pointer-events: none on the container, auto on the children. One-liner fix for a genuinely confusing bug — the element isn't visually blocking anything, it's just intercepting pointer events invisibly.


What I learned

Problem Lesson
HiDPI blurriness Multiply canvas buffer by devicePixelRatio first, before any draw calls
Emoji inconsistency Never use ctx.fillText() with emoji. Draw with canvas paths
Player stuck Scene-aware functions. Entry logic only in world scene. Exit logic only in interior scene
HUD eating taps pointer-events: none on the container, auto on children
Save corruption Always wrap JSON.parse() in try/catch. Fail gracefully to a new game
Building transitions Separate entry and exit into clearly named functions. Don't unify what has different conditions

The single-file constraint forced me to fight for every feature. No build step, no module system, no "I'll just add a dependency." Everything has to justify its weight. That pressure produces surprisingly clean code.

The game works. It plays identically on every device. It has zero dependencies. For a side project that started as "can I build an RPG in pure HTML?" — that feels like success.


Originally published at onlyWebPro

Top comments (0)