[
  {
    "path": "README.md",
    "content": "# URL Snake\n\nPlay the classic snake game on a URL!\n\n<https://demian.ferrei.ro/snake>\n\nThis is how the game should look:\n\n![Pro level gameplay](gameplay.gif)\n\nNote that the game might be unplayable on some browsers for different reasons, like the browser not showing the full URL, or not allowing it to change so frequently, or escaping the Braille characters used to display the game.\n\nAlthough this game is kind of a joke, bug reports, ideas and pull requests are always [welcome](https://github.com/epidemian/snake/issues)!\n"
  },
  {
    "path": "UNLICENSE",
    "content": "This is free and unencumbered software released into the public domain.\n\nAnyone is free to copy, modify, publish, use, compile, sell, or\ndistribute this software, either in source code form or as a compiled\nbinary, for any purpose, commercial or non-commercial, and by any\nmeans.\n\nIn jurisdictions that recognize copyright laws, the author or authors\nof this software dedicate any and all copyright interest in the\nsoftware to the public domain. We make this dedication for the benefit\nof the public at large and to the detriment of our heirs and\nsuccessors. We intend this dedication to be an overt act of\nrelinquishment in perpetuity of all present and future rights to this\nsoftware under copyright law.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\nIN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR\nOTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,\nARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE SOFTWARE.\n\nFor more information, please refer to <http://unlicense.org/>\n"
  },
  {
    "path": "index.html",
    "content": "<!DOCTYPE html>\n<html lang=en>\n<head>\n<meta charset=utf-8>\n<title>URL Snake!</title>\n<meta name=description content=\"Play the classic snake game on the browser URL.\"/>\n<meta name=viewport content=\"width=device-width, initial-scale=1.0\"/>\n<link rel=canonical href=\"/snake/\">\n<link rel=\"shortcut icon\" href=favicon.ico />\n<link rel=stylesheet href=style.css>\n<script data-goatcounter=\"https://demian.goatcounter.com/count\" async src=\"//gc.zgo.at/count.js\"></script>\n<script>\n  if ('ontouchstart' in document.documentElement) {\n    document.documentElement.classList.add('touch')\n  }\n</script>\n</head>\n<noscript>⚠ Sorry, this game requires JavaScript.</noscript>\n<div id=url-container class=invisible>URL: <span id=url></span></div>\n<div class=expandable>\n  <button class=\"help-toggle expand-btn\" aria-label=Help>?</button>\n  <div class=\"expandable-content hidden\">\n    <span class=no-touch-only>Use the arrow keys or WASD to control the snake on the URL.</span>\n    <span class=touch-only>Use the arrows to control the snake on the URL.</span>\n    <a id=reveal-url href=#>Click here</a> if you can't see the page URL<span id=url-escaping-note class=invisible> or if it looks messed up with <span id=replacement-char-description></span></span>.&nbsp;<button class=collapse-btn aria-label=Hide>〈</button>\n  </div>\n</div>\n<div id=max-score-container class=\"expandable hidden\">\n  <button class=\"high-score-toggle expand-btn\" aria-label=\"Max score\">!</button>\n  <div class=\"expandable-content hidden\">\n    Your highest score is <span id=max-score-points></span>!\n    <span id=max-score-grid></span><a id=share href=#><i class=icon-share></i>Share</a>&nbsp;<button class=collapse-btn aria-label=Hide>〈</button><span id=share-note class=invisible></span>\n  </div>\n</div>\n<div class=\"controls touch-only\">\n  <button id=up>▲&#xFE0E;</button>\n  <button id=left>◀&#xFE0E;</button>\n  <button id=down>▼&#xFE0E;</button>\n  <button id=right>▶&#xFE0E;</button>\n</div>\n<footer>\n  Made with (⠠⠇) by <a href=https://twitter.com/epidemian>@epidemian</a> |\n  <a href=https://github.com/epidemian/snake>Code</a>\n</footer>\n<script src=snake.js></script>\n"
  },
  {
    "path": "snake.js",
    "content": "'use strict';\n\nvar GRID_WIDTH = 40;\nvar SNAKE_CELL = 1;\nvar FOOD_CELL = 2;\nvar UP = {x: 0, y: -1};\nvar DOWN = {x: 0, y: 1};\nvar LEFT = {x: -1, y: 0};\nvar RIGHT = {x: 1, y: 0};\nvar INITIAL_SNAKE_LENGTH = 4;\nvar BRAILLE_SPACE = '\\u2800';\n\nvar grid;\nvar snake;\nvar currentDirection;\nvar moveQueue;\nvar hasMoved;\nvar gamePaused = false;\nvar urlRevealed = false;\nvar whitespaceReplacementChar;\n\nfunction main() {\n  detectBrowserUrlWhitespaceEscaping();\n  cleanUrl();\n  setupEventHandlers();\n  drawMaxScore();\n  initUrlRevealed();\n  startGame();\n\n  var lastFrameTime = Date.now();\n  window.requestAnimationFrame(function frameHandler() {\n    var now = Date.now();\n    if (!gamePaused && now - lastFrameTime >= tickTime()) {\n      updateWorld();\n      drawWorld();\n      lastFrameTime = now;\n    }\n    window.requestAnimationFrame(frameHandler);\n  });\n}\n\nfunction detectBrowserUrlWhitespaceEscaping() {\n  // Write two Braille whitespace characters to the hash because Firefox doesn't\n  // escape single WS chars between words.\n  history.replaceState(null, null, '#' + BRAILLE_SPACE + BRAILLE_SPACE)\n  if (location.hash.indexOf(BRAILLE_SPACE) == -1) {\n    console.warn('Browser is escaping whitespace characters on URL')\n    var replacementData = pickWhitespaceReplacementChar();\n    whitespaceReplacementChar = replacementData[0];\n    $('#url-escaping-note').classList.remove('invisible');\n    $('#replacement-char-description').textContent = replacementData[1];\n  }\n}\n\nfunction cleanUrl() {\n  // In order to have the most space for the game, shown on the URL hash,\n  // remove all query string parameters and trailing / from the URL.\n  history.replaceState(null, null, location.pathname.replace(/\\b\\/$/, ''));\n}\n\nfunction setupEventHandlers() {\n  var directionsByKey = {\n    // Arrows\n    37: LEFT, 38: UP, 39: RIGHT, 40: DOWN,\n    // WASD\n    87: UP, 65: LEFT, 83: DOWN, 68: RIGHT,\n    // hjkl\n    75: UP, 72: LEFT, 74: DOWN, 76: RIGHT\n  };\n\n  document.onkeydown = function (event) {\n    var key = event.keyCode;\n    if (key in directionsByKey) {\n      changeDirection(directionsByKey[key]);\n    }\n  };\n\n  // Use touchstart instead of mousedown because these arrows are only shown on\n  // touch devices, and also because there is a delay between touchstart and\n  // mousedown on those devices, and the game should respond ASAP.\n  $('#up').ontouchstart = function () { changeDirection(UP) };\n  $('#down').ontouchstart = function () { changeDirection(DOWN) };\n  $('#left').ontouchstart = function () { changeDirection(LEFT) };\n  $('#right').ontouchstart = function () { changeDirection(RIGHT) };\n\n  window.onblur = function pauseGame() {\n    gamePaused = true;\n    window.history.replaceState(null, null, location.hash + '[paused]');\n  };\n\n  window.onfocus = function unpauseGame() {\n    gamePaused = false;\n    drawWorld();\n  };\n\n  $('#reveal-url').onclick = function (e) {\n    e.preventDefault();\n    setUrlRevealed(!urlRevealed);\n  };\n\n  document.querySelectorAll('.expandable').forEach(function (expandable) {\n    var expand = expandable.querySelector('.expand-btn');\n    var collapse = expandable.querySelector('.collapse-btn');\n    var content = expandable.querySelector('.expandable-content');\n    expand.onclick = collapse.onclick = function () {\n      expand.classList.remove('hidden');\n      content.classList.remove('hidden');\n      expandable.classList.toggle('expanded');\n    };\n    // Hide the expand button or the content when the animation ends so those\n    // elements are not interactive anymore.\n    // Surely there's a way to do this with CSS animations more directly.\n    expandable.ontransitionend = function () {\n      var expanded = expandable.classList.contains('expanded');\n      expand.classList.toggle('hidden', expanded);\n      content.classList.toggle('hidden', !expanded);\n    };\n  });\n}\n\nfunction initUrlRevealed() {\n  setUrlRevealed(Boolean(localStorage.urlRevealed));\n}\n\n// Some browsers don't display the page URL, either partially (e.g. Safari) or\n// entirely (e.g. mobile in-app web-views). To make the game playable in such\n// cases, the player can choose to \"reveal\" the URL within the page body.\nfunction setUrlRevealed(value) {\n  urlRevealed = value;\n  $('#url-container').classList.toggle('invisible', !urlRevealed);\n  if (urlRevealed) {\n    localStorage.urlRevealed = 'y';\n  } else {\n    delete localStorage.urlRevealed;\n  }\n}\n\nfunction startGame() {\n  grid = new Array(GRID_WIDTH * 4);\n  snake = [];\n  for (var x = 0; x < INITIAL_SNAKE_LENGTH; x++) {\n    var y = 2;\n    snake.unshift({x: x, y: y});\n    setCellAt(x, y, SNAKE_CELL);\n  }\n  currentDirection = RIGHT;\n  moveQueue = [];\n  hasMoved = false;\n  dropFood();\n}\n\nfunction updateWorld() {\n  if (moveQueue.length) {\n    currentDirection = moveQueue.pop();\n  }\n\n  var head = snake[0];\n  var tail = snake[snake.length - 1];\n  var newX = head.x + currentDirection.x;\n  var newY = head.y + currentDirection.y;\n\n  var outOfBounds = newX < 0 || newX >= GRID_WIDTH || newY < 0 || newY >= 4;\n  var collidesWithSelf = cellAt(newX, newY) === SNAKE_CELL\n    && !(newX === tail.x && newY === tail.y);\n\n  if (outOfBounds || collidesWithSelf) {\n    endGame();\n    startGame();\n    return;\n  }\n\n  var eatsFood = cellAt(newX, newY) === FOOD_CELL;\n  if (!eatsFood) {\n    snake.pop();\n    setCellAt(tail.x, tail.y, null);\n  }\n\n  // Advance head after tail so it can occupy the same cell on next tick.\n  setCellAt(newX, newY, SNAKE_CELL);\n  snake.unshift({x: newX, y: newY});\n\n  if (eatsFood) {\n    dropFood();\n  }\n}\n\nfunction endGame() {\n  var score = currentScore();\n  var maxScore = parseInt(localStorage.maxScore || 0);\n  if (score > 0 && score > maxScore && hasMoved) {\n    localStorage.maxScore = score;\n    localStorage.maxScoreGrid = gridString();\n    drawMaxScore();\n    showMaxScore();\n  }\n}\n\nfunction drawWorld() {\n  var hash = '#|' + gridString() + '|[score:' + currentScore() + ']';\n\n  if (urlRevealed) {\n    // Use the original game representation on the on-DOM view, as there are no\n    // escaping issues there.\n    $('#url').textContent = location.href.replace(/#.*$/, '') + hash;\n  }\n\n  // Modern browsers escape whitespace characters on the address bar URL for\n  // security reasons. In case this browser does that, replace the empty Braille\n  // character with a non-whitespace (and hopefully non-intrusive) symbol.\n  if (whitespaceReplacementChar) {\n    hash = hash.replace(/\\u2800/g, whitespaceReplacementChar);\n  }\n\n  history.replaceState(null, null, hash);\n\n  // Some browsers have a rate limit on history.replaceState() calls, resulting\n  // in the URL not updating at all for a couple of seconds. In those cases,\n  // location.hash is updated directly, which is unfortunate, as it causes a new\n  // navigation entry to be created each time, effectively hijacking the user's\n  // back button.\n  if (decodeURIComponent(location.hash) !== hash) {\n    console.warn(\n      'history.replaceState() throttling detected. Using location.hash fallback'\n    );\n    location.hash = hash;\n  }\n}\n\nfunction gridString() {\n  var str = '';\n  for (var x = 0; x < GRID_WIDTH; x += 2) {\n    // Unicode Braille patterns are 256 code points going from 0x2800 to 0x28FF.\n    // They follow a binary pattern where the bits are, from least significant\n    // to most: ⠁⠂⠄⠈⠐⠠⡀⢀\n    // So, for example, 147 (10010011) corresponds to ⢓\n    var n = 0\n      | bitAt(x, 0) << 0\n      | bitAt(x, 1) << 1\n      | bitAt(x, 2) << 2\n      | bitAt(x + 1, 0) << 3\n      | bitAt(x + 1, 1) << 4\n      | bitAt(x + 1, 2) << 5\n      | bitAt(x, 3) << 6\n      | bitAt(x + 1, 3) << 7;\n    str += String.fromCharCode(0x2800 + n);\n  }\n  return str;\n}\n\nfunction tickTime() {\n  // Game speed increases as snake grows.\n  var start = 125;\n  var end = 75;\n  return start + snake.length * (end - start) / grid.length;\n}\n\nfunction currentScore() {\n  return snake.length - INITIAL_SNAKE_LENGTH;\n}\n\nfunction cellAt(x, y) {\n  return grid[x % GRID_WIDTH + y * GRID_WIDTH];\n}\n\nfunction bitAt(x, y) {\n  return cellAt(x, y) ? 1 : 0;\n}\n\nfunction setCellAt(x, y, cellType) {\n  grid[x % GRID_WIDTH + y * GRID_WIDTH] = cellType;\n}\n\nfunction dropFood() {\n  var emptyCells = grid.length - snake.length;\n  if (emptyCells === 0) {\n    return;\n  }\n  var dropCounter = Math.floor(Math.random() * emptyCells);\n  for (var i = 0; i < grid.length; i++) {\n    if (grid[i] === SNAKE_CELL) {\n      continue;\n    }\n    if (dropCounter === 0) {\n      grid[i] = FOOD_CELL;\n      break;\n    }\n    dropCounter--;\n  }\n}\n\nfunction changeDirection(newDir) {\n  var lastDir = moveQueue[0] || currentDirection;\n  var opposite = newDir.x + lastDir.x === 0 && newDir.y + lastDir.y === 0;\n  if (!opposite) {\n    // Process moves in a queue to prevent multiple direction changes per tick.\n    moveQueue.unshift(newDir);\n  }\n  hasMoved = true;\n}\n\nfunction drawMaxScore() {\n  var maxScore = localStorage.maxScore;\n  if (maxScore == null) {\n    return;\n  }\n\n  var maxScorePoints = maxScore == 1 ? '1 point' : maxScore + ' points'\n  var maxScoreGrid = localStorage.maxScoreGrid;\n\n  $('#max-score-points').textContent = maxScorePoints;\n  $('#max-score-grid').textContent = maxScoreGrid;\n  $('#max-score-container').classList.remove('hidden');\n\n  $('#share').onclick = function (e) {\n    e.preventDefault();\n    shareScore(maxScorePoints, maxScoreGrid);\n  };\n}\n\n// Expands the high score details if collapsed. Only done when beating the\n// highest score, to grab the player's attention.\nfunction showMaxScore() {\n  if ($('#max-score-container.expanded')) return\n  $('#max-score-container .expand-btn').click();\n}\n\nfunction shareScore(scorePoints, grid) {\n  var message = '|' + grid + '| Got ' + scorePoints +\n    ' playing this stupid snake game on the browser URL!';\n  var url = $('link[rel=canonical]').href;\n  if (navigator.share) {\n    navigator.share({text: message, url: url});\n  } else {\n    navigator.clipboard.writeText(message + '\\n' + url)\n      .then(function () { showShareNote('copied to clipboard') })\n      .catch(function () { showShareNote('clipboard write failed') })\n  }\n}\n\nfunction showShareNote(message) {\n  var note = $(\"#share-note\");\n  note.textContent = message;\n  note.classList.remove(\"invisible\");\n  setTimeout(function () { note.classList.add(\"invisible\") }, 1000);\n}\n\n// Super hacky function to pick a suitable character to replace the empty\n// Braille character (u+2800) when the browser escapes whitespace on the URL.\n// We want to pick a character that's close in width to the empty Braille symbol\n// —so the game doesn't stutter horizontally—, and also pick something that's\n// not too visually noisy. So we actually measure how wide and how \"dark\" some\n// candidate characters are when rendered by the browser (using a canvas) and\n// pick the first that passes both criteria.\nfunction pickWhitespaceReplacementChar() {\n  var candidates = [\n    // U+0ADF is part of the Gujarati Unicode blocks, but it doesn't have an\n    // associated glyph. For some reason, Chrome renders is as totally blank and\n    // almost the same size as the Braille empty character, but it doesn't\n    // escape it on the address bar URL, so this is the perfect replacement\n    // character. This behavior of Chrome is probably a bug, and might be\n    // changed at any time, and in other browsers like Firefox this character is\n    // rendered with an ugly \"undefined\" glyph, so it'll get filtered out by the\n    // width or the \"blankness\" check in either of those cases.\n    ['૟', 'strange symbols'],\n    // U+27CB Mathematical Rising Diagonal, not a great replacement for\n    // whitespace, but is close to the correct size and blank enough.\n    ['⟋', 'some weird slashes']\n  ];\n\n  var N = 5;\n  var canvas = document.createElement('canvas');\n  var ctx = canvas.getContext('2d');\n  ctx.font = '30px system-ui';\n  var targetWidth = ctx.measureText(BRAILLE_SPACE.repeat(N)).width;\n\n  for (var i = 0; i < candidates.length; i++) {\n    var char = candidates[i][0];\n    var str = char.repeat(N);\n    var width = ctx.measureText(str).width;\n    var similarWidth = Math.abs(targetWidth - width) / targetWidth <= 0.1;\n\n    ctx.clearRect(0, 0, canvas.width, canvas.height);\n    ctx.fillText(str, 0, 30);\n    var pixelData = ctx.getImageData(0, 0, width, 30).data;\n    var totalPixels = pixelData.length / 4;\n    var coloredPixels = 0;\n    for (var j = 0; j < totalPixels; j++) {\n      var alpha = pixelData[j * 4 + 3];\n      if (alpha != 0) {\n        coloredPixels++;\n      }\n    }\n    var notTooDark = coloredPixels / totalPixels < 0.15;\n\n    if (similarWidth && notTooDark) {\n      return candidates[i];\n    }\n  }\n\n  // Fallback to a safe U+2591 Light Shade.\n  return ['░', 'some kind of \"fog\"'];\n}\n\nvar $ = document.querySelector.bind(document);\n\nmain();\n"
  },
  {
    "path": "style.css",
    "content": "/* Reset */\nhtml, body, h1, p {\n  margin: 0;\n  padding: 0;\n}\n\n* {\n  box-sizing: border-box;\n}\n\nhtml {\n  height: 100%;\n  font-family: Arial, sans-serif;\n  color: #222;\n  line-height: 1.5;\n}\n\nbody {\n  min-height: 100%;\n  display: flex;\n  flex-direction: column;\n  align-items: flex-start;\n  gap: 4px;\n  padding: 10px;\n}\n\na {\n  color: #3473ee;\n}\n\n.controls {\n  display: grid;\n  grid-template-areas: \".    up   .\"\n                       \"left down right\";\n  /* Limit the size of the controls by the viewport size. */\n  width: 75vmin;\n  height: 50vmin;\n  margin: 10px auto;\n}\n\n#up { grid-area: up; }\n#down { grid-area: down; }\n#left { grid-area: left; }\n#right { grid-area: right; }\n\n.controls button {\n  color: #888;\n  font-size: 8vmin;\n  background: none;\n  border: 2px solid;\n  margin: 2px;\n  border-radius: 5vmin;\n}\n\n.controls button:focus {\n  outline: none;\n}\n\n@media (min-width: 10cm) and (min-height: 10cm) {\n  /* Avoid controls getting too big on larger touch devices. */\n  .controls {\n    position: absolute;\n    width: 7.5cm;\n    height: 5cm;\n    bottom: 1.5cm;\n    right: 1.5cm;\n  }\n  .controls button {\n    font-size: 0.8cm;\n    border-radius: 0.5cm;\n  }\n}\n\n#url,\n#max-score-grid {\n  background: #8883;\n  padding: 2px;\n  border-radius: 3px;\n}\n\n#share {\n  display: inline-block;\n}\n\nfooter {\n  margin-top: auto;\n  font-size: 0.9rem;\n}\n\n.invisible {\n  display: none !important;\n}\n\n.hidden {\n  visibility: hidden;\n}\n\n:root.touch .no-touch-only,\n:root:not(.touch) .touch-only {\n  display: none;\n}\n\n.expand-btn,\n.collapse-btn {\n  background: none;\n  border: none;\n  padding: 0;\n  font: inherit;\n  font-weight: bold;\n  cursor: pointer;\n  width: 1rem;\n}\n\n.expand-btn,\n.expandable {\n  transition: transform, opacity;\n  transition-duration: .4s;\n}\n\n.expandable {\n  display: inline-block;\n  position: relative;\n  height: 1.5rem;\n  transform: translateX(-100%);\n  /* Clear body padding so it doesn't show on the left of the expand-btn */\n  padding-right: 10px;\n}\n\n.expand-btn {\n  position: absolute;\n  right: 0;\n  top: 0;\n  transform: translateX(100%);\n  opacity: 1;\n}\n\n.help-toggle {\n  color: #0bc3ff;;\n}\n\n.high-score-toggle {\n  color: #ff8c0b;\n}\n\n.collapse-btn {\n  color: #aaa;\n}\n\n.expandable.expanded {\n  height: auto;\n  transform: none;\n}\n\n.expandable.expanded .expand-btn {\n  opacity: 0;\n}\n\n#share-note {\n  padding-left: 0.5rem;\n}\n\n#share-note.invisible {\n  /* Only animate fading out. */\n  transition: opacity 0.4s ease-in-out, display 0s ease-out 0.4s;\n  transition-behavior: allow-discrete;\n  opacity: 0;\n  /* Hide with display:none to avoid messing up the position of the collapse button. */\n  display: none;\n}\n\n@media (prefers-color-scheme: dark) {\n  html {\n    background: #222;\n    color: #eee;\n  }\n}\n\n/* Icon font styles copied from Fontello. */\n@font-face {\n  font-family: 'icons';\n  src: url('icons.woff2?40046441') format('woff2');\n  font-weight: normal;\n  font-style: normal;\n}\n[class^=\"icon-\"]:before, [class*=\" icon-\"]:before {\n  font-family: \"icons\";\n  font-style: normal;\n  font-weight: normal;\n  speak: none;\n  display: inline-block;\n  text-decoration: inherit;\n  width: 1em;\n  margin-right: .2em;\n  text-align: center;\n  /* For safety - reset parent styles, that can break glyph codes*/\n  font-variant: normal;\n  text-transform: none;\n  /* Animation center compensation - margins should be symmetric */\n  /* remove if not needed */\n  margin-left: .2em;\n  /* Font smoothing. That was taken from TWBS */\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\n.icon-share:before { content: '\\e811'; }\n"
  }
]