DUCK WARS RPG (by corbun mobile supported)

Duck Wars RPG is a Game Boy-style pixel adventure where you explore a procedurally generated tile map as a morphing duck hero and engage in turn-based RPG battles with randomly encountered enemies. Players can switch between three forms (Duckling, Fire Duck, Wolf), each with unique stats and move sets, while discovering the game world and fighting wild creatures.

🧪 Try This!

Experiment with the code by making these changes:

  1. Change the game's pixel resolution — Lowering GAME_W and GAME_H makes pixels huge and chunky, raising them shows more detail. Try 128×96 for giant pixels or 512×384 for detailed graphics.
  2. Make battles 50% more common — Raise the random encounter threshold from 0.14 to 0.21 so you fight wild creatures more often while exploring.
  3. Speed up player movement — Lower MOVE_DELAY from 8 frames to 4 so the player moves twice as fast through the world.
  4. Recolor the overworld sky — The overworld background uses RGB (88, 136, 104)—change it to a sunset (255, 100, 50) or night sky (20, 20, 60).
  5. Give the Fire Duck more attack power — Increase Fire Duck's attack stat from 12 to 18 to make fire-based combat more powerful.
  6. Make the procedural map more chaotic — Increase noise frequency from 0.15 to 0.35 so terrain changes drastically and creates a patchwork of tiny biomes instead of large zones.
Prefer the full editor? Open it there →

📖 About This Sketch

Duck Wars RPG transforms p5.js into a handheld game console, complete with low-resolution pixel art, a procedurally generated tile world, and a menu-driven battle system. You explore colorful grass, forest, and water tiles as a duck that morphs into three different forms, each with different attack power, defense, and special moves. When you walk through grass or forest, you randomly trigger turn-based RPG battles where you choose from four moves each turn: attack, heal, or morph into a new form.

The code demonstrates three critical techniques for making retro games in p5.js: a low-resolution graphics buffer that gets scaled up for pixel-perfect display, a procedural noise-based infinite map system, and a state machine that switches between 'overworld' exploration and 'battle' combat. You'll also see how touch and keyboard input are unified through a mobile-friendly virtual D-pad. By studying this sketch, you'll learn how professional game engines handle multiple game states, how to build responsive menus, and how to create the illusion of a huge world using noise.

⚙️ How It Works

  1. When the sketch starts, setup() creates a low-resolution graphics buffer (256×192 pixels, like a Game Boy) and initializes the player as a Duckling in the overworld at tile position (0, 0). The main window is scaled to fit the screen while keeping pixels crisp.
  2. Every frame, draw() renders the current game state (overworld or battle) into the low-resolution buffer, then scales and displays it on the main canvas with a border. This two-step process keeps all game logic working at retro resolution while letting players see it at any window size.
  3. In the overworld, getTileType() uses Perlin noise to generate an infinite procedural map—the same tile coordinates always produce the same terrain type (water, grass, or forest). The camera stays centered on the player, scrolling the visible tiles around them as they move.
  4. When the player moves into grass or forest, a 14% random chance triggers startBattle(), which switches gameState to 'battle' and spawns a random enemy with full HP.
  5. During battle, the player chooses one of four moves via keyboard (1–4) or touch buttons. Attacks deal damage calculated from move power, attacker stats, and defender defense with a variance. Morphing switches the player to the next form, healing restores HP, and battles advance through states: start → playerTurn → playerMoveDone → enemyMoveDone → victory/defeat → back to overworld.
  6. The mobile controls (D-pad and A/B buttons) are drawn as touch regions and detect pointer input in screen coordinates, converting them to game buffer space so they work at any window size.

🎓 Concepts You'll Learn

Game state machineLow-resolution graphics bufferProcedural terrain generation with Perlin noiseTurn-based combat logicCanvas scaling and pixel-perfect renderingMobile touch input and virtual controlsCamera followingPolymorphic forms and stats

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. Here it prepares two canvases: a full-screen one for display and a small low-res one where the actual game renders. This two-buffer technique is the foundation of pixel-perfect retro game graphics in p5.js.

function setup() {
  pixelDensity(1);                    // important for crisp pixels
  createCanvas(windowWidth, windowHeight);
  noSmooth();                         // disable smoothing when scaling buffer

  gameG = createGraphics(GAME_W, GAME_H);
  gameG.noSmooth();                   // crisp shapes in buffer too

  noiseSeed(worldSeed);

  player = {
    tileX: 0,
    tileY: 0,
    formIndex: 0,
    hp: playerForms[0].maxHp,
    maxHp: playerForms[0].maxHp,
    facing: 'down'
  };
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

calculation Pixel-perfect initialization pixelDensity(1); createCanvas(windowWidth, windowHeight); noSmooth();

Creates a full-window canvas and disables anti-aliasing so scaled pixels remain sharp and blocky

calculation Low-res game buffer creation gameG = createGraphics(GAME_W, GAME_H); gameG.noSmooth();

Creates the 256×192 offscreen buffer where all game drawing happens before scaling

calculation Player object initialization player = { tileX: 0, tileY: 0, formIndex: 0, hp: playerForms[0].maxHp, maxHp: playerForms[0].maxHp, facing: 'down' };

Sets up the player object with starting position, form, HP, and direction facing

pixelDensity(1);
Prevents p5.js from doubling pixels on high-DPI displays—ensures 1 logical pixel = 1 actual pixel for crisp scaling
createCanvas(windowWidth, windowHeight);
Creates the main display canvas at full window size so the player's screen real estate is used
noSmooth();
Disables anti-aliasing on the main canvas so when the game buffer is scaled up, pixels stay sharp and blocky
gameG = createGraphics(GAME_W, GAME_H);
Creates an offscreen graphics buffer at the low 'game' resolution (256×192) where all rendering happens
gameG.noSmooth();
Also disables smoothing on the game buffer so shapes drawn into it stay crisp
noiseSeed(worldSeed);
Seeds the random noise generator with a fixed value so the same tile coordinates always produce the same terrain type
player = { tileX: 0, tileY: 0, formIndex: 0, hp: playerForms[0].maxHp, maxHp: playerForms[0].maxHp, facing: 'down' };
Initializes the player object at tile (0, 0) as the first form (Duckling) with full HP and facing downward

draw()

draw() is the heartbeat of every p5.js sketch. Here, it renders the current game state into a tiny low-res buffer, then scales that buffer up to fill the screen. This is how retro games keep their blocky pixel aesthetic no matter the screen size.

function draw() {
  updateScreenTransform();

  // Draw into low-res buffer
  gameG.push();
  gameG.clear();

  if (gameState === 'overworld') {
    drawOverworld(gameG);
  } else if (gameState === 'battle') {
    drawBattleScene(gameG);
  }

  gameG.pop();

  // Blit low-res buffer to the main canvas with pixel scaling
  background(10, 20, 20);
  noSmooth();
  image(
    gameG,
    screenOffsetX,
    screenOffsetY,
    GAME_W * screenScale,
    GAME_H * screenScale
  );

  // Simple border like a handheld screen
  noFill();
  stroke(40, 220);
  strokeWeight(2);
  rectMode(CORNER);
  rect(screenOffsetX, screenOffsetY, GAME_W * screenScale, GAME_H * screenScale);
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Game state dispatch if (gameState === 'overworld') { drawOverworld(gameG); } else if (gameState === 'battle') { drawBattleScene(gameG); }

Routes drawing to the appropriate scene function based on current game state

calculation Scale and display game buffer image( gameG, screenOffsetX, screenOffsetY, GAME_W * screenScale, GAME_H * screenScale );

Copies the low-res game buffer to the main canvas, scaled up by an integer factor and centered

updateScreenTransform();
Recalculates the scale factor and centering offset so the game stays centered even if the window is resized
gameG.push();
Saves the current graphics state (fill, stroke, etc.) so changes don't persist between frames
gameG.clear();
Erases the game buffer from the previous frame, preventing trails of old drawings
if (gameState === 'overworld') { drawOverworld(gameG); } else if (gameState === 'battle') { drawBattleScene(gameG); }
Checks the current game state and calls the appropriate drawing function to render the scene into gameG
gameG.pop();
Restores the saved graphics state after drawing is complete
background(10, 20, 20);
Fills the main canvas with a dark blue-green color (like a handheld console bezel) before displaying the game
image(gameG, screenOffsetX, screenOffsetY, GAME_W * screenScale, GAME_H * screenScale);
Draws the small game buffer onto the main canvas, scaled up by screenScale and offset to center it
rect(screenOffsetX, screenOffsetY, GAME_W * screenScale, GAME_H * screenScale);
Draws a thin green border around the scaled game area to frame it like a retro handheld screen

getTileType(tx, ty)

getTileType() uses Perlin noise, a smooth pseudo-random function, to generate terrain procedurally. The same tile coordinate always produces the same noise value, so the map is deterministic—exploring the same tile twice shows the same terrain. This is how infinite game worlds stay consistent.

🔬 These thresholds divide the noise range into terrain types. What happens if you change 0.3 to 0.5 and 0.6 to 0.8? More water or less?

  if (n < 0.3) return 'water';
  if (n < 0.6) return 'grass';
  return 'forest';
function getTileType(tx, ty) {
  // Infinite procedural map using noise
  const scale = 0.15;
  const n = noise(tx * scale, ty * scale);
  if (n < 0.3) return 'water';
  if (n < 0.6) return 'grass';
  return 'forest';
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Perlin noise sampling const n = noise(tx * scale, ty * scale);

Samples Perlin noise at the tile's coordinates scaled by 0.15, producing a value between 0 and 1 that varies smoothly across the map

conditional Terrain type thresholds if (n < 0.3) return 'water'; if (n < 0.6) return 'grass'; return 'forest';

Maps noise ranges to terrain types: low noise = water, mid noise = grass, high noise = forest

const scale = 0.15;
Scales the tile coordinates before passing to noise()—controls how stretched or compressed the noise pattern is (lower = larger biomes)
const n = noise(tx * scale, ty * scale);
Calls p5.js's Perlin noise function, which returns a smooth pseudo-random value between 0 and 1 for any (x, y) coordinate
if (n < 0.3) return 'water';
If noise is very low (0–0.3), the tile is water
if (n < 0.6) return 'grass';
If noise is medium (0.3–0.6), the tile is grass
return 'forest';
Otherwise (0.6–1.0), the tile is forest

drawWorld(g)

drawWorld() is a camera system: it centers on the player and only renders tiles on screen (a huge optimization for infinite maps). Each frame it calls getTileType() to get the terrain at each visible coordinate, assigns a color, and draws it. The screen-space calculation (sx, sy) converts from world tiles to pixel coordinates.

function drawWorld(g) {
  g.rectMode(CENTER);
  g.strokeWeight(1);
  const camX = player.tileX;
  const camY = player.tileY;

  const halfCols = Math.ceil(g.width / TILE_SIZE / 2) + 1;
  const halfRows = Math.ceil(g.height / TILE_SIZE / 2) + 1;

  for (let ty = Math.floor(camY - halfRows); ty <= Math.floor(camY + halfRows); ty++) {
    for (let tx = Math.floor(camX - halfCols); tx <= Math.floor(camX + halfCols); tx++) {
      const tileType = getTileType(tx, ty);
      let c;
      if (tileType === 'water')      c = g.color(46, 84, 136);
      else if (tileType === 'grass') c = g.color(104, 168, 88);
      else                           c = g.color(56, 104, 64);

      g.fill(c);
      g.stroke(0, 40);

      const sx = g.width / 2 + (tx - camX) * TILE_SIZE;
      const sy = g.height / 2 + (ty - camY) * TILE_SIZE;
      g.rect(sx, sy, TILE_SIZE, TILE_SIZE);
    }
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

calculation Camera position and viewport const camX = player.tileX; const camY = player.tileY; const halfCols = Math.ceil(g.width / TILE_SIZE / 2) + 1; const halfRows = Math.ceil(g.height / TILE_SIZE / 2) + 1;

Centers the camera on the player and calculates which tiles are visible on screen

for-loop Tile rendering loop for (let ty = Math.floor(camY - halfRows); ty <= Math.floor(camY + halfRows); ty++) { for (let tx = Math.floor(camX - halfCols); tx <= Math.floor(camX + halfCols); tx++) {

Iterates over all visible tiles in a rectangle around the camera position

conditional Terrain color selection const tileType = getTileType(tx, ty); let c; if (tileType === 'water') c = g.color(46, 84, 136); else if (tileType === 'grass') c = g.color(104, 168, 88); else c = g.color(56, 104, 64);

Looks up the terrain type for this tile and assigns a matching color

const camX = player.tileX;
The camera's tile X position—always centered on the player
const halfCols = Math.ceil(g.width / TILE_SIZE / 2) + 1;
Calculates how many tiles to the left/right of the player are visible—depends on buffer width and tile size
for (let ty = Math.floor(camY - halfRows); ty <= Math.floor(camY + halfRows); ty++) {
Outer loop over all visible rows of tiles (above and below the player)
for (let tx = Math.floor(camX - halfCols); tx <= Math.floor(camX + halfCols); tx++) {
Inner loop over all visible columns of tiles (left and right of the player)
const tileType = getTileType(tx, ty);
Queries the procedural map to get the terrain type at this tile coordinate
g.fill(c); g.stroke(0, 40); g.rect(sx, sy, TILE_SIZE, TILE_SIZE);
Draws a square tile with the terrain color and a dark semi-transparent border

drawPlayer(g)

drawPlayer() is a simple sprite renderer. It uses translate() to center the drawing at the screen center, then draws geometric shapes (ellipses and triangles) at offsets relative to that center. This modular approach makes it easy to redesign the sprite by tweaking multipliers.

function drawPlayer(g) {
  const form = getCurrentForm();
  g.push();
  g.translate(g.width / 2, g.height / 2);
  g.noStroke();

  const bodyColor = form.color;

  // Body
  g.fill(bodyColor[0], bodyColor[1], bodyColor[2]);
  g.ellipse(0, 0, TILE_SIZE * 1.1, TILE_SIZE * 0.8);

  // Head
  g.ellipse(TILE_SIZE * 0.4, -TILE_SIZE * 0.7, TILE_SIZE * 0.9, TILE_SIZE * 0.9);

  // Beak / snout
  g.fill(232, 186, 88);
  g.triangle(
    TILE_SIZE * 0.8, -TILE_SIZE * 0.7,
    TILE_SIZE * 1.3, -TILE_SIZE * 0.6,
    TILE_SIZE * 0.8, -TILE_SIZE * 0.5
  );

  // Eye
  g.fill(0);
  g.ellipse(TILE_SIZE * 0.55, -TILE_SIZE * 0.8, TILE_SIZE * 0.16, TILE_SIZE * 0.16);

  g.pop();
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

calculation Sprite centering and setup g.push(); g.translate(g.width / 2, g.height / 2); g.noStroke();

Centers drawing at the screen center and saves the graphics state for cleanup

calculation Body shape g.fill(bodyColor[0], bodyColor[1], bodyColor[2]); g.ellipse(0, 0, TILE_SIZE * 1.1, TILE_SIZE * 0.8);

Draws the player's body as a horizontal ellipse using the current form's color

calculation Head shape g.ellipse(TILE_SIZE * 0.4, -TILE_SIZE * 0.7, TILE_SIZE * 0.9, TILE_SIZE * 0.9);

Draws a round head positioned above and slightly right of the body

const form = getCurrentForm();
Retrieves the player's current form object to access its color array
g.push();
Saves the current graphics settings (fill, stroke, transform) so changes don't affect later draws
g.translate(g.width / 2, g.height / 2);
Moves the origin to the center of the screen—all following coordinates are relative to this point
g.noStroke();
Removes outlines from shapes so they have clean edges
g.fill(bodyColor[0], bodyColor[1], bodyColor[2]);
Sets the fill color to the form's RGB values from the array
g.ellipse(0, 0, TILE_SIZE * 1.1, TILE_SIZE * 0.8);
Draws the body as an ellipse at (0, 0)—slightly wider than tall for a chunky look
g.ellipse(TILE_SIZE * 0.4, -TILE_SIZE * 0.7, TILE_SIZE * 0.9, TILE_SIZE * 0.9);
Draws a round head offset up and to the right of the body center
g.triangle(...);
Draws a triangle for the beak/snout, pointing right—its color is hardcoded as beige
g.ellipse(TILE_SIZE * 0.55, -TILE_SIZE * 0.8, TILE_SIZE * 0.16, TILE_SIZE * 0.16);
Draws a small black eye on the head
g.pop();
Restores the saved graphics state so the player draw doesn't affect other drawings

drawOverworldHUD(g)

drawOverworldHUD() renders the heads-up display in the overworld state, showing the player's current form, HP bar, and control instructions. The HP bar demonstrates a common game UI pattern: a background bar with a foreground bar scaled by a percentage.

function drawOverworldHUD(g) {
  const form = getCurrentForm();
  const margin = 3;
  const barW = g.width * 0.6;
  const barH = 6;

  g.rectMode(CORNER);
  g.noStroke();
  g.fill(0, 0, 0, 180);
  g.rect(margin, margin, barW + 20, 30, 4);

  g.fill(255);
  g.textAlign(LEFT, TOP);
  g.textSize(8);
  g.text(`${form.name}`, margin + 6, margin + 4);

  const hpPct = player.hp / player.maxHp;
  g.fill(40);
  g.rect(margin + 6, margin + 14, barW, barH, 2);
  g.fill(44, 220, 80);
  g.rect(margin + 6, margin + 14, barW * hpPct, barH, 2);

  g.fill(230);
  g.textSize(7);
  g.text(`${Math.round(player.hp)}/${player.maxHp} HP`, margin + 8, margin + 22);

  // Info
  g.textSize(6);
  g.text(
    'Arrows/WASD or D-pad. Walk in grass/forest for battles.',
    margin + 4,
    margin + 30,
    g.width - margin * 2,
    40
  );

  g.rectMode(CENTER);
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

calculation HUD background panel g.fill(0, 0, 0, 180); g.rect(margin, margin, barW + 20, 30, 4);

Draws a semi-transparent black rounded rectangle at the top-left to hold the form name and HP bar

calculation HP bar rendering const hpPct = player.hp / player.maxHp; g.fill(40); g.rect(margin + 6, margin + 14, barW, barH, 2); g.fill(44, 220, 80); g.rect(margin + 6, margin + 14, barW * hpPct, barH, 2);

Draws a dark background bar then a green bar on top, scaled to show remaining HP percentage

const form = getCurrentForm();
Gets the player's current form to display its name
const barW = g.width * 0.6;
Calculates the HP bar width as 60% of the buffer width
g.fill(0, 0, 0, 180);
Sets fill to semi-transparent black (alpha = 180 out of 255)
g.rect(margin, margin, barW + 20, 30, 4);
Draws the background panel at the top-left with slightly rounded corners (radius 4)
g.text(`${form.name}`, margin + 6, margin + 4);
Displays the form's name in white text
const hpPct = player.hp / player.maxHp;
Calculates the HP ratio (0.0 to 1.0) to scale the green bar
g.rect(margin + 6, margin + 14, barW * hpPct, barH, 2);
Draws the green health bar, scaled to fill only hpPct of the total width
g.text(`${Math.round(player.hp)}/${player.maxHp} HP`, margin + 8, margin + 22);
Displays numeric HP as 'current/max'

drawOverworld(g)

drawOverworld() is a scene orchestrator—it calls all the component drawing functions in order and handles input. This is a common pattern in game engines: one function per scene that delegates to helpers.

function drawOverworld(g) {
  // Slightly tinted sky color
  g.background(88, 136, 104);
  drawWorld(g);
  drawPlayer(g);
  drawOverworldHUD(g);
  drawMobileControls(g);
  handleOverworldInput();
}
Line-by-line explanation (6 lines)
g.background(88, 136, 104);
Clears the buffer with a greenish color—the sky tint for the overworld
drawWorld(g);
Renders all visible tiles (water, grass, forest) around the player
drawPlayer(g);
Renders the player sprite at the center of the screen
drawOverworldHUD(g);
Renders the form name, HP bar, and control instructions
drawMobileControls(g);
Renders the virtual D-pad and button outlines for touch input
handleOverworldInput();
Processes keyboard/touch input to move the player and potentially trigger battles

getCurrentDirection()

getCurrentDirection() unifies keyboard and mobile input. It checks keyboard keys first, then lets the virtual D-pad override them. The && !opposite check prevents diagonal movement: only one axis per frame.

🔬 These four lines define the four directions. What happens if you change the dy value in the first line from -1 to -2? Moving up moves two tiles instead of one, right?

  if (up && !down) return { dx: 0, dy: -1, facing: 'up' };
  if (down && !up) return { dx: 0, dy: 1, facing: 'down' };
  if (left && !right) return { dx: -1, dy: 0, facing: 'left' };
  if (right && !left) return { dx: 1, dy: 0, facing: 'right' };
function getCurrentDirection() {
  let up = keyIsDown(UP_ARROW) || keyIsDown(87);     // W
  let down = keyIsDown(DOWN_ARROW) || keyIsDown(83); // S
  let left = keyIsDown(LEFT_ARROW) || keyIsDown(65); // A
  let right = keyIsDown(RIGHT_ARROW) || keyIsDown(68); // D

  // Mobile / mouse virtual D-pad
  if (mobileDir) {
    up = mobileDir === 'up';
    down = mobileDir === 'down';
    left = mobileDir === 'left';
    right = mobileDir === 'right';
  }

  if (up && !down) return { dx: 0, dy: -1, facing: 'up' };
  if (down && !up) return { dx: 0, dy: 1, facing: 'down' };
  if (left && !right) return { dx: -1, dy: 0, facing: 'left' };
  if (right && !left) return { dx: 1, dy: 0, facing: 'right' };
  return null;
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Keyboard input check let up = keyIsDown(UP_ARROW) || keyIsDown(87); // W let down = keyIsDown(DOWN_ARROW) || keyIsDown(83); // S let left = keyIsDown(LEFT_ARROW) || keyIsDown(65); // A let right = keyIsDown(RIGHT_ARROW) || keyIsDown(68); // D

Checks if arrow keys or WASD keys are currently pressed

conditional Mobile input override if (mobileDir) { up = mobileDir === 'up'; down = mobileDir === 'down'; left = mobileDir === 'left'; right = mobileDir === 'right'; }

If the player is touching the virtual D-pad, override keyboard input with the touch direction

conditional Direction output if (up && !down) return { dx: 0, dy: -1, facing: 'up' }; if (down && !up) return { dx: 0, dy: 1, facing: 'down' }; if (left && !right) return { dx: -1, dy: 0, facing: 'left' }; if (right && !left) return { dx: 1, dy: 0, facing: 'right' }; return null;

Returns the movement vector and facing direction if exactly one axis is pressed, otherwise null

let up = keyIsDown(UP_ARROW) || keyIsDown(87);
Checks if the UP arrow or W key is held down (87 is the key code for W)
if (mobileDir) { up = mobileDir === 'up'; }
If the player is touching the up button on the D-pad, override the keyboard check
if (up && !down) return { dx: 0, dy: -1, facing: 'up' };
If up is pressed but not down, return a direction object: dy = -1 moves up one tile
return null;
If no clear direction is pressed (diagonal or none), return null

handleOverworldInput()

handleOverworldInput() implements the core movement logic: rate-limited step-by-step tile movement, collision detection (can't walk on water), and random encounter triggering. The rate limiter creates the classic 'grid-locked' RPG movement feel.

function handleOverworldInput() {
  const dir = getCurrentDirection();
  if (!dir) return;
  if (frameCount - lastMoveFrame < MOVE_DELAY) return;

  const newX = player.tileX + dir.dx;
  const newY = player.tileY + dir.dy;
  const tileType = getTileType(newX, newY);

  player.facing = dir.facing;
  lastMoveFrame = frameCount;

  if (!isWalkable(tileType)) {
    return;
  }

  player.tileX = newX;
  player.tileY = newY;

  // Random encounters in grass / forest
  if ((tileType === 'grass' || tileType === 'forest') && random() < 0.14) {
    startBattle(tileType);
  }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

conditional Movement rate limiting if (frameCount - lastMoveFrame < MOVE_DELAY) return;

Ensures the player can only move once every MOVE_DELAY frames (8 frames), slowing movement to a comfortable pace

conditional Walkability validation if (!isWalkable(tileType)) { return; }

Prevents movement into water tiles—player can only walk on grass and forest

conditional Random encounter check if ((tileType === 'grass' || tileType === 'forest') && random() < 0.14) { startBattle(tileType); }

14% chance to trigger a battle when moving into grass or forest

const dir = getCurrentDirection();
Retrieves the current input direction (or null if no direction pressed)
if (!dir) return;
If no direction is pressed, exit early—no movement happens
if (frameCount - lastMoveFrame < MOVE_DELAY) return;
If not enough frames have passed since the last move, exit early—prevents movement spam
const newX = player.tileX + dir.dx;
Calculates the next tile X position by adding the direction delta
const tileType = getTileType(newX, newY);
Looks up the terrain type at the new position before actually moving
player.facing = dir.facing;
Updates the player's facing direction even if they can't move (for animations)
lastMoveFrame = frameCount;
Records the current frame count so the rate limiter can measure elapsed frames
if (!isWalkable(tileType)) { return; }
If the terrain isn't walkable (e.g., water), stop here—don't move
player.tileX = newX;
Only now, after all checks pass, actually update the player's position
if ((tileType === 'grass' || tileType === 'forest') && random() < 0.14) { startBattle(tileType); }
14% chance (0.14 probability) to trigger a random battle on grass or forest tiles

drawMobileControls(g)

drawMobileControls() renders an onscreen controller overlay for touch-based play. It uses getControlLayout() to position buttons, draws them with visual feedback for the currently pressed button, and labels them with text.

function drawMobileControls(g) {
  const ui = getControlLayout(g);
  g.rectMode(CORNER);
  g.noStroke();

  // D-pad
  g.fill(0, 0, 0, 120);
  [ui.dUp, ui.dDown, ui.dLeft, ui.dRight].forEach(r => {
    g.rect(r.x, r.y, r.w, r.h, 4);
  });

  // Highlight active direction
  g.fill(255, 255, 255, 150);
  if (mobileDir === 'up') g.rect(ui.dUp.x, ui.dUp.y, ui.dUp.w, ui.dUp.h, 4);
  else if (mobileDir === 'down') g.rect(ui.dDown.x, ui.dDown.y, ui.dDown.w, ui.dDown.h, 4);
  else if (mobileDir === 'left') g.rect(ui.dLeft.x, ui.dLeft.y, ui.dLeft.w, ui.dLeft.h, 4);
  else if (mobileDir === 'right') g.rect(ui.dRight.x, ui.dRight.y, ui.dRight.w, ui.dRight.h, 4);

  // A/B buttons
  g.fill(0, 0, 0, 160);
  g.rect(ui.btnA.x, ui.btnA.y, ui.btnA.w, ui.btnA.h, ui.btnA.w / 2);
  g.rect(ui.btnB.x, ui.btnB.y, ui.btnB.w, ui.btnB.h, ui.btnB.w / 2);

  g.fill(255);
  g.textAlign(CENTER, CENTER);
  g.textSize(ui.btnA.w * 0.45);
  g.text('A', ui.btnA.x + ui.btnA.w / 2, ui.btnA.y + ui.btnA.h / 2);
  g.text('B', ui.btnB.x + ui.btnB.w / 2, ui.btnB.y + ui.btnB.h / 2);

  g.textAlign(LEFT, TOP);
  g.rectMode(CENTER);
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation D-pad rendering g.fill(0, 0, 0, 120); [ui.dUp, ui.dDown, ui.dLeft, ui.dRight].forEach(r => { g.rect(r.x, r.y, r.w, r.h, 4); });

Draws four semi-transparent buttons for up/down/left/right

conditional Active button highlight if (mobileDir === 'up') g.rect(ui.dUp.x, ui.dUp.y, ui.dUp.w, ui.dUp.h, 4);

Brightens the currently pressed D-pad button to provide visual feedback

const ui = getControlLayout(g);
Retrieves the positions and sizes of all touch buttons
[ui.dUp, ui.dDown, ui.dLeft, ui.dRight].forEach(r => { g.rect(...); });
Loops over the four D-pad directions and draws each as a dark rounded rectangle
if (mobileDir === 'up') g.rect(ui.dUp.x, ui.dUp.y, ui.dUp.w, ui.dUp.h, 4);
If the player is currently touching the up button, draw a bright highlight over it
g.rect(ui.btnA.x, ui.btnA.y, ui.btnA.w, ui.btnA.h, ui.btnA.w / 2);
Draws the A button (rounded rectangle with radius = half the button width for a pill shape)
g.text('A', ui.btnA.x + ui.btnA.w / 2, ui.btnA.y + ui.btnA.h / 2);
Draws the letter 'A' centered inside the A button

startBattle(tileType)

startBattle() is the transition point from overworld to battle. It initializes a battle object that tracks enemy form, HP, and state, then switches gameState so the rendering system knows to draw battle graphics.

function startBattle(tileType) {
  gameState = 'battle';
  const enemyForm = random(enemyForms);
  battle = {
    enemy: {
      form: enemyForm,
      hp: enemyForm.maxHp,
      maxHp: enemyForm.maxHp
    },
    state: 'start',
    message: `A wild ${enemyForm.name} appears!`
  };
}
Line-by-line explanation (3 lines)
gameState = 'battle';
Switches the game state to 'battle' so the next draw() call renders the battle scene instead of overworld
const enemyForm = random(enemyForms);
Picks a random enemy form from the enemyForms array
battle = { ... };
Creates the battle object with enemy stats, initial HP, and an opening message

calculateDamage(attackerForm, defenderForm, move)

calculateDamage() is a simple RPG damage formula. It combines attacker stats and move power, subtracts defender defense, adds variance, and ensures a minimum of 1. Change the 0.4 multiplier to rebalance the game.

🔬 This multiplies defense by 0.4. What happens if you change 0.4 to 0.1? Defense matters less, so damage becomes higher, right?

  const reduction = defenderForm.defense * 0.4;
function calculateDamage(attackerForm, defenderForm, move) {
  const base = move.power + attackerForm.attack;
  const reduction = defenderForm.defense * 0.4;
  const raw = base - reduction;
  const variance = random(-2, 2);
  return max(1, round(raw + variance));
}
Line-by-line explanation (5 lines)
const base = move.power + attackerForm.attack;
Combines the move's inherent power with the attacker's attack stat to get base damage
const reduction = defenderForm.defense * 0.4;
Multiplies defender's defense by 0.4 to calculate how much damage is reduced
const raw = base - reduction;
Subtracts defense reduction from base damage
const variance = random(-2, 2);
Adds randomness (-2 to +2) so damage is never exactly the same each time
return max(1, round(raw + variance));
Rounds the final damage and ensures it's at least 1 (to prevent zero or negative damage)

playerChooseMove(index)

playerChooseMove() implements the three move types: attack (deals damage), heal (restores HP), and morph (switch form). It validates the turn state, executes the move, and transitions the battle state machine.

function playerChooseMove(index) {
  if (!battle || battle.state !== 'playerTurn') return;

  const form = getCurrentForm();
  if (!form.moves[index]) return;

  const move = form.moves[index];

  if (move.kind === 'attack') {
    const dmg = calculateDamage(form, battle.enemy.form, move);
    battle.enemy.hp = max(0, battle.enemy.hp - dmg);
    battle.message = `You used ${move.name}! It dealt ${dmg} damage.`;
  } else if (move.kind === 'heal') {
    const before = player.hp;
    player.hp = min(player.maxHp, player.hp + move.power);
    const healed = round(player.hp - before);
    battle.message = `You used ${move.name}! You healed ${healed} HP.`;
  } else if (move.kind === 'morph') {
    morphToNextForm();
    const newForm = getCurrentForm();
    battle.message = `You morphed into ${newForm.name}!`;
  }

  battle.state = 'playerMoveDone';
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Attack move handling if (move.kind === 'attack') { const dmg = calculateDamage(form, battle.enemy.form, move); battle.enemy.hp = max(0, battle.enemy.hp - dmg); battle.message = `You used ${move.name}! It dealt ${dmg} damage.`; }

Calculates damage and reduces enemy HP

conditional Heal move handling else if (move.kind === 'heal') { const before = player.hp; player.hp = min(player.maxHp, player.hp + move.power); const healed = round(player.hp - before); battle.message = `You used ${move.name}! You healed ${healed} HP.`; }

Restores player HP up to max and displays how much was healed

conditional Morph move handling else if (move.kind === 'morph') { morphToNextForm(); const newForm = getCurrentForm(); battle.message = `You morphed into ${newForm.name}!`; }

Switches the player to the next form and updates the message

if (!battle || battle.state !== 'playerTurn') return;
Validates that a battle is active and it's the player's turn—if not, ignore the move choice
const form = getCurrentForm();
Retrieves the player's current form to access its moves
if (!form.moves[index]) return;
Checks that the selected move index exists—prevents crashes from invalid selections
const move = form.moves[index];
Gets the move object at the chosen index
battle.enemy.hp = max(0, battle.enemy.hp - dmg);
Subtracts damage from enemy HP but prevents it from going below 0
player.hp = min(player.maxHp, player.hp + move.power);
Adds healing but caps player HP at maxHp
battle.state = 'playerMoveDone';
Marks the player's turn as complete so battle logic can advance to enemy turn

doEnemyTurn()

doEnemyTurn() is enemy AI: pick a random move, calculate damage, deal it to the player, and advance the battle state. Simple but effective for a turn-based RPG.

function doEnemyTurn() {
  const enemy = battle.enemy;
  const move = random(enemy.form.moves);
  const dmg = calculateDamage(enemy.form, getCurrentForm(), move);
  player.hp = max(0, player.hp - dmg);
  battle.state = 'enemyMoveDone';
  battle.message = `${enemy.form.name} used ${move.name}! It dealt ${dmg} damage.`;
}
Line-by-line explanation (4 lines)
const move = random(enemy.form.moves);
Picks a random move from the enemy's available moves
const dmg = calculateDamage(enemy.form, getCurrentForm(), move);
Calculates how much damage the enemy's move deals to the player
player.hp = max(0, player.hp - dmg);
Reduces player HP but prevents it from going below 0
battle.state = 'enemyMoveDone';
Marks enemy's action as complete so battle can check for win/loss or return to player turn

advanceBattleState()

advanceBattleState() is the battle state machine—a sequence of if-statements that move the battle through states (start → playerTurn → playerMoveDone → enemy turn → victory/defeat). This pattern is fundamental to turn-based games.

function advanceBattleState() {
  if (!battle) return;

  if (battle.state === 'start') {
    battle.state = 'playerTurn';
    battle.message = 'Choose a move.';
  } else if (battle.state === 'playerMoveDone') {
    if (battle.enemy.hp <= 0) {
      battle.state = 'victory';
      battle.message = `You defeated ${battle.enemy.form.name}!`;
    } else {
      doEnemyTurn();
    }
  } else if (battle.state === 'enemyMoveDone') {
    if (player.hp <= 0) {
      battle.state = 'defeat';
      battle.message = 'You fainted...';
    } else {
      battle.state = 'playerTurn';
      battle.message = 'Choose a move.';
    }
  } else if (battle.state === 'victory' || battle.state === 'defeat') {
    endBattle();
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Battle state machine if (battle.state === 'start') { ... } else if (battle.state === 'playerMoveDone') { ... }

Routes the battle flow through states: start → playerTurn → playerMoveDone → check victory/enemy turn → enemyMoveDone → check defeat/player turn → victory/defeat → end

if (battle.state === 'start') { battle.state = 'playerTurn'; }
Transitions from the intro state to the player's turn
if (battle.enemy.hp <= 0) { battle.state = 'victory'; } else { doEnemyTurn(); }
Checks if enemy died—if yes, victory; if no, let enemy attack
if (player.hp <= 0) { battle.state = 'defeat'; } else { battle.state = 'playerTurn'; }
After enemy's move, check if player died—if yes, defeat; if no, return to player's turn
else if (battle.state === 'victory' || battle.state === 'defeat') { endBattle(); }
When battle ends (victory or defeat), call endBattle() to reset state and return to overworld

endBattle()

endBattle() cleans up after a battle: it heals the player, nullifies the battle object, and switches back to the overworld state. This is the game-flow equivalent of a 'cleanup function.'

function endBattle() {
  // Simple recovery so you can keep exploring
  player.hp = min(player.maxHp, player.hp + round(player.maxHp * 0.5));
  battle = null;
  gameState = 'overworld';
}
Line-by-line explanation (3 lines)
player.hp = min(player.maxHp, player.hp + round(player.maxHp * 0.5));
Heals the player for 50% of max HP as a reward for winning or losing, allowing continuous exploration
battle = null;
Clears the battle object so battle logic won't execute in future frames
gameState = 'overworld';
Switches gameState back to 'overworld' so draw() renders the map again

drawBattleScene(g)

drawBattleScene() is the battle screen layout: a grassy arena background, two ground ovals (like shadows), enemy sprite on the right, player sprite on the left, and HUD elements. It's a classic RPG setup.

function drawBattleScene(g) {
  // Background like a grassy arena
  g.background(68, 112, 84);

  // Ground ovals
  g.noStroke();
  g.fill(60, 132, 80);
  const groundY = g.height * 0.7;
  g.ellipse(g.width * 0.25, groundY, g.width * 0.35, g.height * 0.14);
  g.ellipse(g.width * 0.75, groundY - 18, g.width * 0.35, g.height * 0.14);

  // Enemy
  const enemy = battle.enemy;
  drawCreatureSprite(g, g.width * 0.75, groundY - 34, enemy.form);

  // Player
  drawCreatureSprite(g, g.width * 0.25, groundY + 4, getCurrentForm());

  drawBattleHUD(g);
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Arena background and ground g.background(68, 112, 84); g.fill(60, 132, 80); g.ellipse(g.width * 0.25, groundY, g.width * 0.35, g.height * 0.14);

Draws the battle arena with greenish background and two ground ovals

g.background(68, 112, 84);
Clears the buffer with a greenish color (the battle arena)
const groundY = g.height * 0.7;
Places the ground area 70% down the screen
g.ellipse(g.width * 0.25, groundY, g.width * 0.35, g.height * 0.14);
Draws a ground shadow oval for the player (25% from left)
drawCreatureSprite(g, g.width * 0.75, groundY - 34, enemy.form);
Draws the enemy sprite at 75% from left, positioned above the ground
drawBattleHUD(g);
Renders HP bars, messages, and move buttons

drawCreatureSprite(g, x, y, form)

drawCreatureSprite() draws any creature (player or enemy) in battle using the form's color. It uses translate() to position the sprite and draws shapes relative to that center—a reusable sprite renderer.

function drawCreatureSprite(g, x, y, form) {
  g.push();
  g.translate(x, y);
  g.noStroke();
  g.fill(form.color[0], form.color[1], form.color[2]);
  const size = min(g.width, g.height) * 0.12;
  g.ellipse(0, 0, size * 1.1, size * 0.7);
  g.ellipse(size * 0.3, -size * 0.45, size * 0.5, size * 0.5);
  g.fill(240, 192, 96);
  g.triangle(
    size * 0.5, -size * 0.45,
    size * 0.9, -size * 0.35,
    size * 0.5, -size * 0.25
  );
  g.fill(0);
  g.ellipse(size * 0.55, -size * 0.45, size * 0.08, size * 0.08);
  g.pop();
}
Line-by-line explanation (8 lines)
g.push();
Saves graphics state so the sprite's translation doesn't affect other draws
g.translate(x, y);
Moves the origin to the sprite's position
const size = min(g.width, g.height) * 0.12;
Calculates sprite size as 12% of the buffer's smallest dimension
g.ellipse(0, 0, size * 1.1, size * 0.7);
Draws the body (horizontal ellipse)
g.ellipse(size * 0.3, -size * 0.45, size * 0.5, size * 0.5);
Draws a round head positioned above and right of the body
g.triangle(...);
Draws a triangle beak/snout in beige color
g.fill(0); g.ellipse(...);
Draws a small black eye on the head
g.pop();
Restores graphics state

drawBattleHUD(g)

drawBattleHUD() is a dense UI function: it renders enemy and player HP bars (red vs. green), the current battle message, and either move selection buttons or a 'continue' prompt depending on battle state. It's the primary interaction surface.

function drawBattleHUD(g) {
  g.rectMode(CORNER);
  g.textAlign(LEFT, TOP);
  g.textSize(8);

  // Enemy HP box (top-right)
  const enemyBoxW = g.width * 0.55;
  const enemyBoxX = g.width - enemyBoxW - 6;
  const enemyBoxY = 6;

  g.fill(0, 0, 0, 180);
  g.rect(enemyBoxX, enemyBoxY, enemyBoxW, 30, 4);
  g.fill(255);
  g.text(battle.enemy.form.name, enemyBoxX + 6, enemyBoxY + 4);

  let pct = battle.enemy.hp / battle.enemy.maxHp;
  g.fill(40);
  g.rect(enemyBoxX + 6, enemyBoxY + 14, enemyBoxW - 12, 6, 2);
  g.fill(232, 64, 72);
  g.rect(enemyBoxX + 6, enemyBoxY + 14, (enemyBoxW - 12) * pct, 6, 2);

  // Player HP box (middle-left)
  const form = getCurrentForm();
  const playerBoxW = g.width * 0.55;
  const playerBoxX = 6;
  const playerBoxY = g.height * 0.38;

  g.fill(0, 0, 0, 180);
  g.rect(playerBoxX, playerBoxY, playerBoxW, 34, 4);
  g.fill(255);
  g.text(form.name, playerBoxX + 6, playerBoxY + 4);

  pct = player.hp / player.maxHp;
  g.fill(40);
  g.rect(playerBoxX + 6, playerBoxY + 16, playerBoxW - 12, 6, 2);
  g.fill(44, 220, 80);
  g.rect(playerBoxX + 6, playerBoxY + 16, (playerBoxW - 12) * pct, 6, 2);

  g.textSize(7);
  g.text(
    `${round(player.hp)}/${player.maxHp} HP`,
    playerBoxX + 6,
    playerBoxY + 24
  );

  // Message & moves
  const layout = getBattleMoveLayout(g);
  const panelY = layout.panelY;
  const panelH = layout.panelH;

  g.fill(0, 0, 0, 200);
  g.rect(0, panelY, g.width, panelH);

  g.fill(255);
  g.textSize(8);
  g.text(battle.message, 6, panelY + 4, g.width - 12, panelH * 0.45);

  if (battle.state === 'playerTurn') {
    const moves = getCurrentForm().moves;
    layout.rects.forEach((r, i) => {
      const move = moves[i];
      g.fill(32, 64, 120);
      g.rect(r.x, r.y, r.w, r.h, 4);
      g.fill(255);
      g.textSize(7);
      g.text(
        move ? `${i + 1}. ${move.name}` : `${i + 1}. -`,
        r.x + 4,
        r.y + 4,
        r.w - 8,
        r.h - 8
      );
    });
  } else {
    g.textSize(7);
    g.text('Tap/click or press ENTER/SPACE to continue', 6, g.height - 10);
  }

  g.rectMode(CENTER);
  g.textAlign(LEFT, TOP);
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

calculation Enemy HP display let pct = battle.enemy.hp / battle.enemy.maxHp; g.fill(40); g.rect(enemyBoxX + 6, enemyBoxY + 14, enemyBoxW - 12, 6, 2); g.fill(232, 64, 72); g.rect(enemyBoxX + 6, enemyBoxY + 14, (enemyBoxW - 12) * pct, 6, 2);

Draws a red health bar showing enemy's remaining HP

calculation Player HP display pct = player.hp / player.maxHp; g.fill(40); g.rect(playerBoxX + 6, playerBoxY + 16, playerBoxW - 12, 6, 2); g.fill(44, 220, 80); g.rect(playerBoxX + 6, playerBoxY + 16, (playerBoxW - 12) * pct, 6, 2);

Draws a green health bar showing player's remaining HP

conditional Move selection or continue prompt if (battle.state === 'playerTurn') { const moves = getCurrentForm().moves; layout.rects.forEach((r, i) => { ... }); } else { g.text('Tap/click or press ENTER/SPACE to continue', ...); }

Shows move buttons when it's the player's turn, otherwise shows a 'continue' prompt

const enemyBoxW = g.width * 0.55;
Enemy HP box is 55% of buffer width
let pct = battle.enemy.hp / battle.enemy.maxHp;
Calculates HP as a fraction (0.0 to 1.0)
g.rect(enemyBoxX + 6, enemyBoxY + 14, (enemyBoxW - 12) * pct, 6, 2);
Draws the red HP bar scaled to pct width—smaller HP means narrower bar
if (battle.state === 'playerTurn') { ... } else { ... }
If it's the player's turn, show move buttons; otherwise show 'continue' message

morphToNextForm()

morphToNextForm() cycles through duck/fire duck/wolf forms, preserving the player's HP percentage during the morph. This mechanic lets players adapt to battle situations.

function morphToNextForm() {
  const oldMax = player.maxHp;
  const oldHpRatio = player.hp / oldMax;

  player.formIndex = (player.formIndex + 1) % playerForms.length;

  const newForm = getCurrentForm();
  player.maxHp = newForm.maxHp;
  player.hp = max(1, round(player.maxHp * oldHpRatio));
  player.hp = min(player.hp, player.maxHp);
}
Line-by-line explanation (5 lines)
const oldMax = player.maxHp;
Saves the current form's max HP before switching
const oldHpRatio = player.hp / oldMax;
Calculates the current HP as a percentage of max
player.formIndex = (player.formIndex + 1) % playerForms.length;
Cycles to the next form (% ensures it wraps around from last form to first)
player.hp = max(1, round(player.maxHp * oldHpRatio));
Applies the old HP percentage to the new form's max HP, rounded and at least 1
player.hp = min(player.hp, player.maxHp);
Caps HP so it doesn't exceed the new form's max

getCurrentForm()

getCurrentForm() is a simple accessor function that retrieves the player's active form. It's used throughout to access the form's name, moves, color, and stats.

function getCurrentForm() {
  return playerForms[player.formIndex];
}
Line-by-line explanation (1 lines)
return playerForms[player.formIndex];
Returns the form object at the player's current formIndex

handlePointerDown(px, py)

handlePointerDown() unifies touch input for both overworld and battle. In overworld, it maps taps to D-pad directions; in battle, it delegates to battle-specific handling.

function handlePointerDown(px, py) {
  if (gameState === 'battle') {
    handleBattlePointer(px, py);
    return;
  }

  const ui = getControlLayout(gameG);
  if (pointInRect(px, py, ui.dUp)) mobileDir = 'up';
  else if (pointInRect(px, py, ui.dDown)) mobileDir = 'down';
  else if (pointInRect(px, py, ui.dLeft)) mobileDir = 'left';
  else if (pointInRect(px, py, ui.dRight)) mobileDir = 'right';
  // A/B not used yet in overworld
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Battle pointer dispatch if (gameState === 'battle') { handleBattlePointer(px, py); return; }

Routes touch events to battle handling if in battle, otherwise to overworld D-pad

if (gameState === 'battle') { handleBattlePointer(px, py); return; }
If in battle, delegate to battle pointer handler and exit
const ui = getControlLayout(gameG);
Retrieves the button layout positions
if (pointInRect(px, py, ui.dUp)) mobileDir = 'up';
If the touch is inside the up button rect, set mobileDir to 'up'

handleBattlePointer(px, py)

handleBattlePointer() implements touch input for battles: during the player's turn, it checks which move button was tapped and executes it; otherwise, it advances to the next battle state.

function handleBattlePointer(px, py) {
  if (!battle) return;

  if (battle.state === 'playerTurn') {
    const layout = getBattleMoveLayout(gameG);
    const rects = layout.rects;
    for (let i = 0; i < rects.length; i++) {
      if (pointInRect(px, py, rects[i])) {
        playerChooseMove(i);
        return;
      }
    }
    // tap outside buttons: ignore
  } else {
    advanceBattleState();
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

for-loop Move button hit detection for (let i = 0; i < rects.length; i++) { if (pointInRect(px, py, rects[i])) { playerChooseMove(i); return; } }

Iterates through move buttons to check if the tap landed on one

if (battle.state === 'playerTurn') { ... } else { advanceBattleState(); }
If it's player's turn, check for move button taps; otherwise advance the battle (e.g., show results)
for (let i = 0; i < rects.length; i++) { if (pointInRect(px, py, rects[i])) { playerChooseMove(i); } }
Loops through the four move buttons and calls playerChooseMove() if one is tapped

getControlLayout(g)

getControlLayout() calculates responsive button positions based on buffer size. All positions are relative to the scale factor, so controls adapt to any resolution.

function getControlLayout(g) {
  const s = min(g.width, g.height) / 4.5;
  const centerX = s * 0.9;
  const centerY = g.height - s * 0.9;
  const dSize = s * 0.6;

  const dUp = { x: centerX - dSize / 2, y: centerY - dSize * 1.1, w: dSize, h: dSize };
  const dDown = { x: centerX - dSize / 2, y: centerY + dSize * 0.1, w: dSize, h: dSize };
  const dLeft = { x: centerX - dSize * 1.6, y: centerY - dSize / 2, w: dSize, h: dSize };
  const dRight = { x: centerX + dSize * 0.6, y: centerY - dSize / 2, w: dSize, h: dSize };

  const btnSize = s * 0.55;
  const btnA = {
    x: g.width - btnSize * 1.4,
    y: g.height - btnSize * 1.8,
    w: btnSize,
    h: btnSize
  };
  const btnB = {
    x: g.width - btnSize * 2.6,
    y: g.height - btnSize * 1.25,
    w: btnSize,
    h: btnSize
  };

  return { dUp, dDown, dLeft, dRight, btnA, btnB };
}
Line-by-line explanation (5 lines)
const s = min(g.width, g.height) / 4.5;
Calculates a scale factor based on buffer size (22% of smaller dimension)
const centerX = s * 0.9;
Places the D-pad center 90% of the scale factor from the left
const dUp = { x: centerX - dSize / 2, y: centerY - dSize * 1.1, w: dSize, h: dSize };
Positions the up button directly above center
const btnSize = s * 0.55;
Makes A and B buttons 55% of the scale factor
return { dUp, dDown, dLeft, dRight, btnA, btnB };
Returns all button positions as a layout object

updateScreenTransform()

updateScreenTransform() recalculates scaling and centering every frame. This ensures the game stays centered and pixel-perfect when the window resizes.

function updateScreenTransform() {
  const s = floor(min(width / GAME_W, height / GAME_H));
  screenScale = max(1, s);

  const totalW = GAME_W * screenScale;
  const totalH = GAME_H * screenScale;

  screenOffsetX = floor((width - totalW) / 2);
  screenOffsetY = floor((height - totalH) / 2);
}
Line-by-line explanation (4 lines)
const s = floor(min(width / GAME_W, height / GAME_H));
Calculates the largest integer scale factor that fits the game buffer on screen
screenScale = max(1, s);
Ensures scale is at least 1 (never shrink the game)
screenOffsetX = floor((width - totalW) / 2);
Centers the scaled game buffer horizontally
screenOffsetY = floor((height - totalH) / 2);
Centers the scaled game buffer vertically

screenToGame(px, py)

screenToGame() converts screen-space tap coordinates to game-buffer coordinates. This is essential for accurate touch input hit detection on a scaled, centered buffer.

function screenToGame(px, py) {
  const gx = floor((px - screenOffsetX) / screenScale);
  const gy = floor((py - screenOffsetY) / screenScale);
  if (gx < 0 || gy < 0 || gx >= GAME_W || gy >= GAME_H) return null;
  return { x: gx, y: gy };
}
Line-by-line explanation (3 lines)
const gx = floor((px - screenOffsetX) / screenScale);
Converts a screen X pixel to a game buffer X pixel by subtracting offset and dividing by scale
if (gx < 0 || gy < 0 || gx >= GAME_W || gy >= GAME_H) return null;
Clamps the result to game buffer bounds—returns null if the tap is outside the game area
return { x: gx, y: gy };
Returns the converted game buffer coordinates

keyPressed()

keyPressed() adds keyboard shortcuts for battle: 1–4 to choose moves, and Enter/Space/Z to continue. This makes gameplay faster on desktop.

function keyPressed() {
  if (gameState === 'battle') {
    if (battle.state === 'playerTurn') {
      if (key === '1') playerChooseMove(0);
      else if (key === '2') playerChooseMove(1);
      else if (key === '3') playerChooseMove(2);
      else if (key === '4') playerChooseMove(3);
    } else {
      if (keyCode === ENTER || key === ' ' || key === 'z' || key === 'Z') {
        advanceBattleState();
      }
    }
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Number key to move index if (key === '1') playerChooseMove(0); else if (key === '2') playerChooseMove(1);

Maps keyboard keys 1–4 to move indices 0–3

if (gameState === 'battle') { ... }
Only process battle input during battle state
if (key === '1') playerChooseMove(0);
If player presses 1, choose move 0; 2→1, 3→2, 4→3
if (keyCode === ENTER || key === ' ' || key === 'z' || key === 'Z') { advanceBattleState(); }
If player presses Enter, Space, or Z, advance the battle (useful when waiting for results)

📦 Key Variables

GAME_W number

The logical game width in pixels (256)—defines the low-res buffer width

const GAME_W = 256;
GAME_H number

The logical game height in pixels (192)—defines the low-res buffer height

const GAME_H = 192;
TILE_SIZE number

Width and height of each map tile in pixels (16)—smaller tiles fit more terrain on screen

const TILE_SIZE = 16;
MOVE_DELAY number

Frames between allowed player movements (8)—prevents instant teleportation

const MOVE_DELAY = 8;
gameG object

The low-resolution offscreen graphics buffer where all game rendering happens before scaling

let gameG = createGraphics(GAME_W, GAME_H);
screenScale number

Integer scale factor applied when displaying the game buffer on the main canvas

let screenScale = 1;
gameState string

Tracks the current game mode: 'overworld' or 'battle'

let gameState = 'overworld';
player object

The player object storing tileX, tileY, formIndex, hp, maxHp, and facing direction

let player = { tileX: 0, tileY: 0, formIndex: 0, hp: 35, maxHp: 35, facing: 'down' };
battle object

Active battle object (or null) tracking enemy, state, and messages during combat

let battle = { enemy: { form: ..., hp: 25, maxHp: 25 }, state: 'start', message: '...' };
mobileDir string

Stores the currently pressed direction on the virtual D-pad: 'up', 'down', 'left', 'right', or null

let mobileDir = null;
playerForms array

Array of form objects (Duckling, Fire Duck, Wolf) with stats, HP, attacks, and moves

const playerForms = [{ id: 'duckling', name: 'Duckling', maxHp: 35, ... }];
enemyForms array

Array of random enemy forms (Wild Pond Duck, Forest Fox, etc.) that can appear in battles

const enemyForms = [{ name: 'Wild Pond Duck', maxHp: 25, ... }];

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG getTileType()

At noise values near exactly 0.3 or 0.6, floating-point rounding could cause inconsistent terrain assignment.

💡 Add a small epsilon value when comparing: if (n < 0.3 + 0.001) return 'water'; This ensures consistent terrain across frame boundaries.

PERFORMANCE drawWorld()

Every frame, getTileType() is called for every visible tile by calling noise() repeatedly. For a 16×12 viewport, that's 192 noise evaluations per frame.

💡 Cache terrain chunks: generate and store a 'TerrainChunk' object for each region every time it's first visited, then look it up instead of recalculating. This trades memory for CPU.

STYLE playerForms and enemyForms

Hard-coded color arrays [222, 246, 170] are scattered throughout and difficult to reuse or tweak globally.

💡 Extract colors to a colors object: const COLORS = { duckling: [222, 246, 170], fireDuck: [242, 176, 88] }; Then reference them as COLORS.duckling instead of inline arrays.

FEATURE drawBattleScene()

Battle animations are static—no visual feedback when damage is dealt or enemies faint.

💡 Add a battle.animationTime counter that increments each frame. Use it to shake the screen, flash colors, or slide sprites during attacks. Reset it when the animation completes.

BUG screenToGame()

If the window is smaller than the game buffer, screenScale becomes 0, causing division by zero or incorrect coordinate conversion.

💡 The fix is already in updateScreenTransform() (max(1, s)), but ensure screenToGame() always receives a valid screenScale >= 1, or add a defensive check.

FEATURE Battle system

Enemies only have 2 moves each, so AI choices are predictable. Players never need strategy.

💡 Add enemy AI: track player HP and form, then choose moves strategically (e.g., if player is low, use high-damage move; if low on HP, heal). This creates engaging battles.

🔄 Code Flow

Code flow showing setup, draw, gettilet, drawworld, drawplayer, drawoveroworldhud, drawoverworld, getcurrentdirection, handleoverworldinput, drawmobilecontrols, startbattle, calculateDamage, playerChooseMove, doenemyturn, advancebattlestate, endbattle, drawbattlescene, drawcreaturesprite, drawbattlehud, morphtonextform, getcurrentform, handlepointerdown, handlebattlepointer, getcontrollayout, updatescreentransform, screentogame, keypressed

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> pixel-setup[Pixel-perfect initialization] setup --> buffer-setup[Low-res game buffer creation] setup --> player-init[Player object initialization] setup --> draw[draw loop] click setup href "#fn-setup" click pixel-setup href "#sub-pixel-setup" click buffer-setup href "#sub-buffer-setup" click player-init href "#sub-player-init" draw --> updatescreentransform[Update screen transform] draw --> state-dispatch[Game state dispatch] draw --> buffer-blit[Scale and display game buffer] click draw href "#fn-draw" click updatescreentransform href "#sub-update-screen-transform" click state-dispatch href "#sub-state-dispatch" click buffer-blit href "#sub-buffer-blit" state-dispatch --> drawoverworld[drawoverworld] state-dispatch --> drawbattlescene[drawbattlescene] click drawoverworld href "#fn-drawoverworld" click drawbattlescene href "#fn-drawbattlescene" drawoverworld --> drawworld[drawworld] drawoverworld --> drawmobilecontrols[drawMobileControls] drawoverworld --> drawoverworldhud[drawoverworldHUD] click drawworld href "#fn-drawworld" click drawmobilecontrols href "#fn-drawmobilecontrols" click drawoverworldhud href "#fn-drawoverworldhud" drawworld --> camera-setup[Camera position and viewport] drawworld --> tile-render-loop[Tile rendering loop] click camera-setup href "#sub-camera-setup" click tile-render-loop href "#sub-tile-render-loop" tile-render-loop --> noise-sample[Perlin noise sampling] tile-render-loop --> tile-color-assign[Terrain color selection] click noise-sample href "#sub-noise-sample" click tile-color-assign href "#sub-tile-color-assign" drawmobilecontrols --> dpad-draw[D-pad rendering] drawmobilecontrols --> active-highlight[Active button highlight] click dpad-draw href "#sub-dpad-draw" click active-highlight href "#sub-active-highlight" drawoverworldhud --> hud-background[HUD background panel] drawoverworldhud --> hp-bar[HP bar rendering] click hud-background href "#sub-hud-background" click hp-bar href "#sub-hp-bar" drawbattlescene --> arena-setup[Arena background and ground] drawbattlescene --> enemy-hp-panel[Enemy HP display] drawbattlescene --> player-hp-panel[Player HP display] drawbattlescene --> move-menu[Move selection or continue prompt] click arena-setup href "#sub-arena-setup" click enemy-hp-panel href "#sub-enemy-hp-panel" click player-hp-panel href "#sub-player-hp-panel" click move-menu href "#sub-move-menu" handleoverworldinput[handleOverworldInput] --> keyboard-input[Keyboard input check] handleoverworldinput --> mobile-override[Mobile input override] handleoverworldinput --> direction-return[Direction output] handleoverworldinput --> move-rate-limit[Movement rate limiting] handleoverworldinput --> walkability-check[Walkability validation] handleoverworldinput --> encounter-trigger[Random encounter check] click handleoverworldinput href "#fn-handleoverworldinput" click keyboard-input href "#sub-keyboard-input" click mobile-override href "#sub-mobile-override" click direction-return href "#sub-direction-return" click move-rate-limit href "#sub-move-rate-limit" click walkability-check href "#sub-walkability-check" click encounter-trigger href "#sub-encounter-trigger" startbattle[startBattle] --> endbattle[endBattle] startbattle --> calculateDamage[Calculate Damage] startbattle --> playerChooseMove[playerChooseMove] startbattle --> doenemyturn[doEnemyTurn] startbattle --> advancebattlestate[advanceBattleState] click startbattle href "#fn-startbattle" click endbattle href "#fn-endbattle" click calculateDamage href "#fn-calculateDamage" click playerChooseMove href "#fn-playerChooseMove" click doenemyturn href "#fn-doenemyturn" click advancebattlestate href "#fn-advancebattlestate" playerChooseMove --> attack-branch[Attack move handling] playerChooseMove --> heal-branch[Heal move handling] playerChooseMove --> morph-branch[Morph move handling] click attack-branch href "#sub-attack-branch" click heal-branch href "#sub-heal-branch" click morph-branch href "#sub-morph-branch" advancebattlestate --> state-machine[Battle state machine] click state-machine href "#sub-state-machine" handlepointerdown[handlePointerDown] --> battle-dispatch[Battle pointer dispatch] battle-dispatch --> move-button-check[Move button hit detection] battle-dispatch --> number-key-dispatch[Number key to move index] click handlepointerdown href "#fn-handlepointerdown" click battle-dispatch href "#sub-battle-dispatch" click move-button-check href "#sub-move-button-check" click number-key-dispatch href "#sub-number-key-dispatch"

❓ Frequently Asked Questions

What kind of visual experience does the DUCK WARS RPG sketch provide?

The sketch offers a colorful, pixelated world reminiscent of Game Boy graphics, where players explore vibrant tile maps as a morphing duck hero.

How can players interact with the DUCK WARS RPG game?

Users can navigate the world and engage in turn-based RPG battles by selecting different duck and wolf forms and moves through a simple, menu-driven combat system.

What creative coding techniques are showcased in the DUCK WARS RPG sketch?

The sketch demonstrates techniques such as tile map generation, character morphing, and turn-based combat mechanics within a retro-style pixel art framework.

Preview

DUCK WARS RPG (by corbun mobile supported) - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of DUCK WARS RPG (by corbun mobile supported) - Code flow showing setup, draw, gettilet, drawworld, drawplayer, drawoveroworldhud, drawoverworld, getcurrentdirection, handleoverworldinput, drawmobilecontrols, startbattle, calculateDamage, playerChooseMove, doenemyturn, advancebattlestate, endbattle, drawbattlescene, drawcreaturesprite, drawbattlehud, morphtonextform, getcurrentform, handlepointerdown, handlebattlepointer, getcontrollayout, updatescreentransform, screentogame, keypressed
Code Flow Diagram