PAC-MAN — JS1K 2019 Entry #4122.35 — 1024 bytes

A fully playable Pac-Man clone squeezed into exactly 1,024 bytes of JavaScript — including the original map layout, ghost AI, mouth animation, and food counter. Use arrow keys to play.

Authorfeiss
GitHubgithub.com/feiss
Websitefeiss.be
Competitioncanvas
Year2019 (10th anniversary JS1K)
Bytes1024 / 1024

The author’s main goal was preserving the original Pac-Man map layout and gameplay feel. Ghost AI is intentionally minimal — a pseudo-random turn derived from the current direction plus a global frame counter. The final code was packed with babel-minify then regpack (a string-pattern compressor) to reach the 1024-byte limit.

Key Techniques
  • Maze stored as 13-bit binary integers — only the left half, mirrored at runtime to build the symmetric right side
  • Canvas API aliased via c[methodName[0] + methodName[6]] — turns fillRect into fc, translate into ta, etc.
  • Ghost AI is one expression: (direction + frameCounter) % 4 + 1 — picks a new direction on each wall collision
  • Smooth sub-pixel movement via 10-step interpolation: pixel = origin + (target − origin) × step / 10
  • Mouth animation uses a triangle wave: Math.abs((frameCounter / 6) % 2 − 1.1)
  • All five entities (4 ghosts + Pac-Man) share one loop — Pac-Man is simply index 4
  • Keyboard mapped as 41 − event.which to convert arrow key codes into direction values 1–4
//b.style.background = "#000";
// map
M=[g=8190, h=4226, h, h, g+1, l=4240, l,
  l=8094, 130, 130, k=159, j=144, j, 8176,
  j, j, k, j, j, g, h, h, 7423, j=1168,
  j, l, k=4098, k, g+1, f=t=0 ];
C=[];

// canvas trick
for(i in c) c[i[0]+i[6]] = c[i]
for(i in M) {
  // convert map numbers to binary
  m = M[i].toString(2).padStart(13,0);
  // duplicate and flip to build right half
  for(j in m) m+=m[m.length-j*2-1];
  M[i] = m.split('');
  C[i] = []
}
F = (x,y,w,h,p)=>{ c.fillStyle='#'+p;c.fc(x,y,w,h,p) }
N = r=>{
  //fill all in blue
  F(0,0,l,l,'009')
  for(i in M) for(j in M[i]){
    +M[i][j] && F(8+j*16, 8+i*16, 30, 30,111);
    if (r) {
      // reset map
      C[i][j] = +M[i][j];
      g=h=>({x:h,y:h,z:h,w:h,Z:h,W:h,d:(t++%4)+1,s:9})
      // 4 ghosts and pacman
      G=[g(25),g(5),g(0),g(17),g(16)]
      k=4
    }
  }
}

N(1)
c.lineWidth= 12;

onkeydown=e=>k=41-e.which; // <4 ^3 >2 v1 .0
setInterval(()=>{
  // draw map
  N()
  // draw food
  S = 298;
  for(i in M) for(j in M[i]){
   // if there's food in this cell, paint it and decrement counter
   C[i][j] && F(22+j*16, 22+i*16, 3, 3, 'eee', S--);
  }
  // draw score
  c.fx(S,220,220);
  for(i in G) with(G[i]){
    if (s>9){
      x = z;
      y = w;
      z -= d%2?0:d-3; // advance x
      w -= d%2?d-2:0; // advance y
      s = 0 // restart microstep counter
    }
    // pacman turning in intersections
    if (i == 4 && d != k){
      Z = x - (k%2?0:k-3);
      W = y - (k%2?k-2:0);
      if(+M[W][Z]){
        d = k;
        z = Z;
        w = W;
        s = 0
      }
    }
    if(w<0 || !+M[w][z]){
      z = x; w = y; // restore prev position
      d = i==4?k:(d+t)%4+1; // pseudo-random turn. This is all the AI there is :P
      s = 7
    }
    if(i<4) {
      // game over
      if (z==G[4].x && w==G[4].y) N(1)
      // draw ghosts
      c.ta(X=12 + (x + (z - x)*s/10) * 16, Y=10 + (y + (w - y)*s/10) * 16);
      //select color
      h=['f77','c70','d22','09c'][i]
      // body
      F(1,5,22,21,h);
      F(4,2,16,8,h);
      F(6,23,13,3,111); // gap between legs
      F(4,8,16,7,'FFF'); //white of eyes
      F(7,8,11,4,111); // pupils
      F(10,0,4,26,h); // top of head + eyes separation + middle leg
      c.ta(-X,-Y);
    }
    if(i==4) { // draw pacman
      // eat
      C[y][x] = 0;
      f = d%2?d/2:d%3;
      m = (t++/6)%2-1.1; // mouth animation
      if(m<0) m=-m; // abs
      c.strokeStyle='#ff0';
      c.ba();
      c.arc(22 + (x + (z - x)*s/10) * 16, 22 + (y + (w - y)*s/10) * 16, 5, 3.1*f+m, 3.1*f-m);
      c.stroke();
    }
    s++;
  }
}, 22)
var methodName
for (methodName in c) {
    c[methodName[0] + methodName[6]] = c[methodName]
}

var mazeRows = [
    8190, 4226, 4226, 4226, 8191, 4240, 4240,
    8094,  130,  130,  159,  144,  144, 8176,
     144,  144,  159,  144,  144, 8190, 4226,
    4226, 7423, 1168, 1168, 8094, 4098, 4098,
    8191
]

var foodGrid = []
var frameCounter = 0
var facingAngle = 0
var entities
var playerDirection

;(function buildMaze() {
    var row, col, halfRow
    for (row in mazeRows) {
        halfRow = mazeRows[row].toString(2).padStart(13, 0)
        for (col in halfRow) {
            halfRow += halfRow[halfRow.length - col * 2 - 1]
        }
        mazeRows[row] = halfRow.split('')
        foodGrid[row] = []
    }
}())

function fillBlock(x, y, w, h, color) {
    c.fillStyle = '#' + color
    c.fc(x, y, w, h)
}

function makeEntity(startPos) {
    return {
        col:       startPos,
        row:       startPos,
        nextCol:   startPos,
        nextRow:   startPos,
        turnCol:   startPos,
        turnRow:   startPos,
        direction: (frameCounter++ % 4) + 1,
        step:      9
    }
}

function drawScene(resetGame) {
    var row, col
    fillBlock(0, 0, 500, 500, '009')
    for (row in mazeRows) {
        for (col in mazeRows[row]) {
            if (+mazeRows[row][col]) {
                fillBlock(8 + col * 16, 8 + row * 16, 30, 30, '111')
            }
            if (resetGame) {
                foodGrid[row][col] = +mazeRows[row][col]
            }
        }
    }
    if (resetGame) {
        entities = [
            makeEntity(25),
            makeEntity(5),
            makeEntity(0),
            makeEntity(17),
            makeEntity(16)
        ]
        playerDirection = 4
    }
}

drawScene(true)
c.lineWidth = 12

onkeydown = function handleKey(event) {
    playerDirection = 41 - event.which
}

function gameTick() {
    var row, col, i, entity, ghostColor, pixelX, pixelY, mouthAngle

    drawScene()

    var foodRemaining = 298
    for (row in mazeRows) {
        for (col in mazeRows[row]) {
            if (foodGrid[row][col]) {
                fillBlock(22 + col * 16, 22 + row * 16, 3, 3, 'eee')
                foodRemaining -= 1
            }
        }
    }
    c.fx(foodRemaining, 220, 220)

    for (i in entities) {
        entity = entities[i]

        if (entity.step > 9) {
            entity.col     = entity.nextCol
            entity.row     = entity.nextRow
            entity.nextCol -= (entity.direction % 2) ? 0 : entity.direction - 3
            entity.nextRow -= (entity.direction % 2) ? entity.direction - 2 : 0
            entity.step    = 0
        }

        if (i == 4 && entity.direction !== playerDirection) {
            entity.turnCol = entity.col - ((playerDirection % 2) ? 0 : playerDirection - 3)
            entity.turnRow = entity.row - ((playerDirection % 2) ? playerDirection - 2 : 0)
            if (+mazeRows[entity.turnRow][entity.turnCol]) {
                entity.direction = playerDirection
                entity.nextCol   = entity.turnCol
                entity.nextRow   = entity.turnRow
                entity.step      = 0
            }
        }

        if (entity.nextRow < 0 || entity.nextRow >= mazeRows.length || !+mazeRows[entity.nextRow][entity.nextCol]) {
            entity.nextCol   = entity.col
            entity.nextRow   = entity.row
            entity.direction = (i == 4) ? playerDirection : (entity.direction + frameCounter) % 4 + 1
            entity.step      = 7
        }

        if (i < 4) {
            if (entity.nextCol === entities[4].col && entity.nextRow === entities[4].row) {
                drawScene(true)
            }
            pixelX = 12 + (entity.col + (entity.nextCol - entity.col) * entity.step / 10) * 16
            pixelY = 10 + (entity.row + (entity.nextRow - entity.row) * entity.step / 10) * 16
            c.ta(pixelX, pixelY)
            ghostColor = ['f77', 'c70', 'd22', '09c'][i]
            fillBlock( 1,  5, 22, 21, ghostColor)
            fillBlock( 4,  2, 16,  8, ghostColor)
            fillBlock( 6, 23, 13,  3, '111')
            fillBlock( 4,  8, 16,  7, 'FFF')
            fillBlock( 7,  8, 11,  4, '111')
            fillBlock(10,  0,  4, 26, ghostColor)
            c.ta(-pixelX, -pixelY)
        }

        if (i == 4) {
            foodGrid[entity.row][entity.col] = 0
            facingAngle = (entity.direction % 2) ? entity.direction / 2 : entity.direction % 3
            mouthAngle = (frameCounter++ / 6) % 2 - 1.1
            if (mouthAngle < 0) {
                mouthAngle = -mouthAngle
            }
            pixelX = 22 + (entity.col + (entity.nextCol - entity.col) * entity.step / 10) * 16
            pixelY = 22 + (entity.row + (entity.nextRow - entity.row) * entity.step / 10) * 16
            c.strokeStyle = '#ff0'
            c.ba()
            c.arc(pixelX, pixelY, 5, 3.1 * facingAngle + mouthAngle, 3.1 * facingAngle - mouthAngle)
            c.stroke()
        }

        entity.step += 1
    }
}

setInterval(gameTick, 22)
/* JS1K 2019 #4122 - PAC-MAN by feiss Commented version: every line explained Iterate over every property of the canvas context object c. Build a 2-character shorthand alias from the 1st and 7th character of the name. e.g. "fillRect"[0]+"fillRect"[6] = "f"+"c" = "fc", so c.fc = c.fillRect e.g. "translate"[0]+"translate"[6] = "t"+"a" = "ta", so c.ta = c.translate e.g. "beginPath"[0]+"beginPath"[6] = "b"+"a" = "ba", so c.ba = c.beginPath e.g. "fillText"[0]+"fillText"[6] = "f"+"x" = "fx", so c.fx = c.fillText */
var methodName for (methodName in c) { c[methodName[0] + methodName[6]] = c[methodName] }
/* Each number encodes one row of the LEFT half of the 26-column maze as 13 binary bits. 1 = wall tile (drawn as a blue block, and also the cell type entities move along) 0 = open visual corridor (the gap between wall blocks; entities cannot enter) Only 13 columns stored; the right 13 are built by mirroring at runtime. */
var mazeRows = [ 8190, 4226, 4226, 4226, 8191, 4240, 4240,// rows 0-6 8094, 130, 130, 159, 144, 144, 8176,// rows 7-13 144, 144, 159, 144, 144, 8190, 4226,// rows 14-20 4226, 7423, 1168, 1168, 8094, 4098, 4098,// rows 21-27 8191// row 28 ]
// foodGrid[row][col] holds whether a food pellet exists at that cell (1=yes, 0=eaten)
var foodGrid = []
// frameCounter is incremented each time Pac-Man is drawn; drives mouth open/close animation
var frameCounter = 0
// facingAngle rotates Pac-Man's arc so the mouth gap faces his direction of travel
var facingAngle = 0
// entities[0..3] are the four ghosts; entities[4] is Pac-Man
var entities
// playerDirection is set by arrow keys: 1=down 2=right 3=up 4=left
var playerDirection
// Convert each integer in mazeRows into a full 26-element array of '0'/'1' characters.
// Wrapped in an IIFE so row/col/halfRow don't pollute the global scope.
;(function buildMaze() { var row, col, halfRow for (row in mazeRows) {
// Convert the integer to a 13-character binary string (the left half)
halfRow = mazeRows[row].toString(2).padStart(13, 0)
// Mirror: iterate col 0..12, each time appending the symmetric character.
// halfRow.length - col*2 - 1 walks inward from the right end.
for (col in halfRow) { halfRow += halfRow[halfRow.length - col * 2 - 1] }
// Replace the integer with the 26-element character array
mazeRows[row] = halfRow.split('')
// Initialise the food grid row as an empty array (filled in drawScene)
foodGrid[row] = [] } }())
// Draw a filled rectangle using the aliased fillRect (c.fc).
// color is a 3-char hex shorthand: '009'=#000099 (blue bg), '111'=#111111 (wall)
function fillBlock(x, y, w, h, color) { c.fillStyle = '#' + color// set the fill colour c.fc(x, y, w, h)// c.fc = c.fillRect, draw the rectangle }
// drawScene() redraws the maze background every frame.
// drawScene(true) also resets food and respawns all entities (game start / game over).
function drawScene(resetGame) { var row, col fillBlock(0, 0, 500, 500, '009')// fill entire canvas dark blue (clear frame) for (row in mazeRows) { for (col in mazeRows[row]) {
// +mazeRows[row][col] coerces '1'->1 (truthy) and '0'->0 (falsy)
if (+mazeRows[row][col]) {
// Draw a dark wall tile 30x30px; grid spacing is 16px with 8px margin
fillBlock(8 + col * 16, 8 + row * 16, 30, 30, '111') } if (resetGame) {
// Seed food: 1 on wall cells (which is where entities travel and eat)
foodGrid[row][col] = +mazeRows[row][col] } } } if (resetGame) { entities = [ makeEntity(25),// ghost 1 - red makeEntity(5),// ghost 2 - orange makeEntity(0),// ghost 3 - dark red makeEntity(17),// ghost 4 - cyan makeEntity(16)// Pac-Man ] playerDirection = 4// start moving up (direction 4 = up) } }
/* Build one entity object. startPos sets col AND row to the same value (a deliberate shortcut: entities start on wall cells and bounce to open positions). direction cycles 1-4 using frameCounter so each entity starts facing differently. step=9 means the very next tick (step+=1 -> 10 > 9) triggers the first tile advance. */
function makeEntity(startPos) { return { col: startPos,// current grid column (integer) row: startPos,// current grid row (integer) nextCol: startPos,// target column being moved toward nextRow: startPos,// target row being moved toward turnCol: startPos,// lookahead column when testing a direction change turnRow: startPos,// lookahead row when testing a direction change direction: (frameCounter++ % 4) + 1,// starting direction 1-4 step: 9// sub-step counter; drives smooth interpolation } } drawScene(true)// first draw: build maze and spawn all entities c.lineWidth = 12// stroke width for Pac-Man's arc (his body outline)
/* Map arrow key codes to direction values 1-4. Key codes: Left=37, Up=38, Right=39, Down=40 41 - 37 = 4 (left), 41 - 38 = 3 (up), 41 - 39 = 2 (right), 41 - 40 = 1 (down) */
onkeydown = function handleKey(event) { playerDirection = 41 - event.which }
// Main game loop, called every 22ms (approx 45fps).
function gameTick() { var row, col, i, entity, ghostColor, pixelX, pixelY, mouthAngle drawScene()// repaint the maze walls (clears previous frame)
// Draw food pellets and count how many remain.
// foodRemaining starts at 298 and decrements for each pellet drawn.
var foodRemaining = 298 for (row in mazeRows) { for (col in mazeRows[row]) { if (foodGrid[row][col]) {
// Draw a 3x3 white dot centred in the cell
fillBlock(22 + col * 16, 22 + row * 16, 3, 3, 'eee') foodRemaining -= 1 } } }
// c.fx = c.fillText; display score as remaining pellet count
c.fx(foodRemaining, 220, 220) for (i in entities) { entity = entities[i]
TILE ADVANCE: once step exceeds 9, the entity has crossed a full tile. Commit the move: current position becomes the target position. Then compute the NEXT target tile in the current direction. Direction encoding: Odd directions (1=down, 3=up) change the row Even directions (2=right, 4=left) change the column nextCol formula: nextCol -= (direction%2) ? 0 : direction-3 direction=2: nextCol -= (2-3) = nextCol += 1 (move right) direction=4: nextCol -= (4-3) = nextCol -= 1 (move left) direction=1 or 3: subtract 0 (column unchanged) nextRow formula: nextRow -= (direction%2) ? direction-2 : 0 direction=1: nextRow -= (1-2) = nextRow += 1 (move down) direction=3: nextRow -= (3-2) = nextRow -= 1 (move up) direction=2 or 4: subtract 0 (row unchanged)
if (entity.step > 9) { entity.col = entity.nextCol// commit column entity.row = entity.nextRow// commit row entity.nextCol -= (entity.direction % 2) ? 0 : entity.direction - 3 entity.nextRow -= (entity.direction % 2) ? entity.direction - 2 : 0 entity.step = 0// reset sub-step counter }
/* DIRECTION CHANGE (Pac-Man only, index 4): When the player presses a new direction, look one step ahead in that direction. If the lookahead cell is a wall cell (value '1', truthy), the turn is valid. Pac-Man can only move along wall cells; '0' cells are impassable gaps. */
if (i == 4 && entity.direction !== playerDirection) {
// Compute the lookahead tile using the same direction-to-delta formula
entity.turnCol = entity.col - ((playerDirection % 2) ? 0 : playerDirection - 3) entity.turnRow = entity.row - ((playerDirection % 2) ? playerDirection - 2 : 0)
// +mazeRows[turnRow][turnCol] is truthy (1) for wall cells = valid to enter
if (+mazeRows[entity.turnRow][entity.turnCol]) { entity.direction = playerDirection// commit the new direction entity.nextCol = entity.turnCol// move toward the lookahead tile entity.nextRow = entity.turnRow entity.step = 0// restart sub-step from zero } }
/* WALL COLLISION: if the next tile is out of bounds or is a '0' (gap) cell, bounce. entity.nextRow < 0 catches the top boundary entity.nextRow >= mazeRows.length catches the bottom boundary !+mazeRows[nextRow][nextCol] fires when the cell value is '0' (open gap) */
if (entity.nextRow < 0 || entity.nextRow >= mazeRows.length || !+mazeRows[entity.nextRow][entity.nextCol]) { entity.nextCol = entity.col// snap target back to current position entity.nextRow = entity.row if (i == 4) {
// Pac-Man: keep trying the player's desired direction
entity.direction = playerDirection } else {
/* Ghost AI: pick a new direction using frame counter for pseudo-randomness. (entity.direction + frameCounter) % 4 + 1 gives a value 1-4 that varies each time a ghost hits a wall, producing different turns per ghost. */
entity.direction = (entity.direction + frameCounter) % 4 + 1 } entity.step = 7// short delay before attempting to move again }
// DRAW GHOST (entities 0-3):
if (i < 4) {
// Collision detection: if ghost target tile matches Pac-Man's current tile, reset
if (entity.nextCol === entities[4].col && entity.nextRow === entities[4].row) { drawScene(true);// ghost caught Pac-Man: full game reset }
/* Smooth interpolated pixel position: rendered = currentTile + (targetTile - currentTile) * step/10 At step=0: renders at currentTile. At step=9: nearly at targetTile. */
pixelX = 12 + (entity.col + (entity.nextCol - entity.col) * entity.step / 10) * 16 pixelY = 10 + (entity.row + (entity.nextRow - entity.row) * entity.step / 10) * 16 c.ta(pixelX, pixelY)// c.ta = c.translate; shift canvas origin to ghost ghostColor = ['f77', 'c70', 'd22', '09c'][i]// one colour per ghost
// Ghost sprite built from 6 fillRect calls (all coords relative to translate):
fillBlock( 1, 5, 22, 21, ghostColor)// main body block fillBlock( 4, 2, 16, 8, ghostColor)// head dome (rounded top) fillBlock( 6, 23, 13, 3, '111')// gap between the two legs fillBlock( 4, 8, 16, 7, 'FFF')// white eye area fillBlock( 7, 8, 11, 4, '111')// dark pupils inside eye whites fillBlock(10, 0, 4, 26, ghostColor)// vertical stripe: top + leg divider c.ta(-pixelX, -pixelY)// undo the translate; restore canvas origin }
// DRAW PAC-MAN (entity index 4):
if (i == 4) {
// Remove food pellet at Pac-Man's current integer tile position
foodGrid[entity.row][entity.col] = 0
/* Compute rotation so the mouth gap faces direction of travel. Maps direction 1-4 to multiples of ~pi/2 (using 3.1 as approximation of pi): direction=1: 1/2 = 0.5 direction=2: 2%3 = 2 (wrong; should be 0) direction=3: 3/2 = 1.5 direction=4: 4%3 = 1 The formula is a byte-saving trick; imprecise but visually acceptable. */
facingAngle = (entity.direction % 2) ? entity.direction / 2 : entity.direction % 3
/* Triangle-wave mouth animation without Math.sin: (frameCounter/6)%2 oscillates 0->1->0 slowly (period=12 frames) Subtract 1.1 -> range -1.1 to +0.9; take abs -> range 0.1 to 1.1 Result: mouthAngle smoothly opens and closes each ~12 frames */
mouthAngle = (frameCounter++ / 6) % 2 - 1.1 if (mouthAngle < 0) { mouthAngle = -mouthAngle// manual Math.abs() to save bytes }
// Interpolated pixel position (same formula as ghosts)
pixelX = 22 + (entity.col + (entity.nextCol - entity.col) * entity.step / 10) * 16 pixelY = 22 + (entity.row + (entity.nextRow - entity.row) * entity.step / 10) * 16 c.strokeStyle = '#ff0'// bright yellow stroke colour c.ba()// c.ba = c.beginPath; start a new path
/* Draw an arc with a wedge-shaped gap (the mouth): startAngle = 3.1 * facingAngle + mouthAngle (one jaw) endAngle = 3.1 * facingAngle - mouthAngle (other jaw) The gap width is 2 * mouthAngle radians, centred on facingAngle * pi */
c.arc(pixelX, pixelY, 5, 3.1 * facingAngle + mouthAngle, 3.1 * facingAngle - mouthAngle) c.stroke()// render the arc outline } entity.step += 1// advance sub-step; at step=10 the next tile advance triggers } } setInterval(gameTick, 22)// start the game loop: 22ms interval = approx 45fps
The full HTML document injected into the iframe — including the JS1K shim and the game source.