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 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
I Built a Malaysian Cultural RPG in a Single HTML File — Here's What Broke
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.