├── README.md ├── UNLICENSE ├── favicon.ico ├── gameplay.gif ├── icons.woff2 ├── index.html ├── snake.js └── style.css /README.md: -------------------------------------------------------------------------------- 1 | # URL Snake 2 | 3 | Play the classic snake game on a URL! 4 | 5 | 6 | 7 | This is how the game should look: 8 | 9 | ![Pro level gameplay](gameplay.gif) 10 | 11 | Note 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. 12 | 13 | Although this game is kind of a joke, bug reports, ideas and pull requests are always [welcome](https://github.com/epidemian/snake/issues)! 14 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epidemian/snake/e9d5591a613afabc7e119a995d718767b1b15987/favicon.ico -------------------------------------------------------------------------------- /gameplay.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epidemian/snake/e9d5591a613afabc7e119a995d718767b1b15987/gameplay.gif -------------------------------------------------------------------------------- /icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epidemian/snake/e9d5591a613afabc7e119a995d718767b1b15987/icons.woff2 -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | URL Snake! 6 | 7 | 8 | 9 | 10 | 11 | 12 | 17 | 18 | 19 | 20 | 28 | 35 |
36 | 37 | 38 | 39 | 40 |
41 | 45 | 46 | -------------------------------------------------------------------------------- /snake.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var GRID_WIDTH = 40; 4 | var SNAKE_CELL = 1; 5 | var FOOD_CELL = 2; 6 | var UP = {x: 0, y: -1}; 7 | var DOWN = {x: 0, y: 1}; 8 | var LEFT = {x: -1, y: 0}; 9 | var RIGHT = {x: 1, y: 0}; 10 | var INITIAL_SNAKE_LENGTH = 4; 11 | var BRAILLE_SPACE = '\u2800'; 12 | 13 | var grid; 14 | var snake; 15 | var currentDirection; 16 | var moveQueue; 17 | var hasMoved; 18 | var gamePaused = false; 19 | var urlRevealed = false; 20 | var whitespaceReplacementChar; 21 | 22 | function main() { 23 | detectBrowserUrlWhitespaceEscaping(); 24 | cleanUrl(); 25 | setupEventHandlers(); 26 | drawMaxScore(); 27 | initUrlRevealed(); 28 | startGame(); 29 | 30 | var lastFrameTime = Date.now(); 31 | window.requestAnimationFrame(function frameHandler() { 32 | var now = Date.now(); 33 | if (!gamePaused && now - lastFrameTime >= tickTime()) { 34 | updateWorld(); 35 | drawWorld(); 36 | lastFrameTime = now; 37 | } 38 | window.requestAnimationFrame(frameHandler); 39 | }); 40 | } 41 | 42 | function detectBrowserUrlWhitespaceEscaping() { 43 | // Write two Braille whitespace characters to the hash because Firefox doesn't 44 | // escape single WS chars between words. 45 | history.replaceState(null, null, '#' + BRAILLE_SPACE + BRAILLE_SPACE) 46 | if (location.hash.indexOf(BRAILLE_SPACE) == -1) { 47 | console.warn('Browser is escaping whitespace characters on URL') 48 | var replacementData = pickWhitespaceReplacementChar(); 49 | whitespaceReplacementChar = replacementData[0]; 50 | $('#url-escaping-note').classList.remove('invisible'); 51 | $('#replacement-char-description').textContent = replacementData[1]; 52 | } 53 | } 54 | 55 | function cleanUrl() { 56 | // In order to have the most space for the game, shown on the URL hash, 57 | // remove all query string parameters and trailing / from the URL. 58 | history.replaceState(null, null, location.pathname.replace(/\b\/$/, '')); 59 | } 60 | 61 | function setupEventHandlers() { 62 | var directionsByKey = { 63 | // Arrows 64 | 37: LEFT, 38: UP, 39: RIGHT, 40: DOWN, 65 | // WASD 66 | 87: UP, 65: LEFT, 83: DOWN, 68: RIGHT 67 | }; 68 | 69 | document.onkeydown = function (event) { 70 | var key = event.keyCode; 71 | if (key in directionsByKey) { 72 | changeDirection(directionsByKey[key]); 73 | } 74 | }; 75 | 76 | // Use touchstart instead of mousedown because these arrows are only shown on 77 | // touch devices, and also because there is a delay between touchstart and 78 | // mousedown on those devices, and the game should respond ASAP. 79 | $('#up').ontouchstart = function () { changeDirection(UP) }; 80 | $('#down').ontouchstart = function () { changeDirection(DOWN) }; 81 | $('#left').ontouchstart = function () { changeDirection(LEFT) }; 82 | $('#right').ontouchstart = function () { changeDirection(RIGHT) }; 83 | 84 | window.onblur = function pauseGame() { 85 | gamePaused = true; 86 | window.history.replaceState(null, null, location.hash + '[paused]'); 87 | }; 88 | 89 | window.onfocus = function unpauseGame() { 90 | gamePaused = false; 91 | drawWorld(); 92 | }; 93 | 94 | $('#reveal-url').onclick = function (e) { 95 | e.preventDefault(); 96 | setUrlRevealed(!urlRevealed); 97 | }; 98 | 99 | document.querySelectorAll('.expandable').forEach(function (expandable) { 100 | var expand = expandable.querySelector('.expand-btn'); 101 | var collapse = expandable.querySelector('.collapse-btn'); 102 | var content = expandable.querySelector('.expandable-content'); 103 | expand.onclick = collapse.onclick = function () { 104 | expand.classList.remove('hidden'); 105 | content.classList.remove('hidden'); 106 | expandable.classList.toggle('expanded'); 107 | }; 108 | // Hide the expand button or the content when the animation ends so those 109 | // elements are not interactive anymore. 110 | // Surely there's a way to do this with CSS animations more directly. 111 | expandable.ontransitionend = function () { 112 | var expanded = expandable.classList.contains('expanded'); 113 | expand.classList.toggle('hidden', expanded); 114 | content.classList.toggle('hidden', !expanded); 115 | }; 116 | }); 117 | } 118 | 119 | function initUrlRevealed() { 120 | setUrlRevealed(Boolean(localStorage.urlRevealed)); 121 | } 122 | 123 | // Some browsers don't display the page URL, either partially (e.g. Safari) or 124 | // entirely (e.g. mobile in-app web-views). To make the game playable in such 125 | // cases, the player can choose to "reveal" the URL within the page body. 126 | function setUrlRevealed(value) { 127 | urlRevealed = value; 128 | $('#url-container').classList.toggle('invisible', !urlRevealed); 129 | if (urlRevealed) { 130 | localStorage.urlRevealed = 'y'; 131 | } else { 132 | delete localStorage.urlRevealed; 133 | } 134 | } 135 | 136 | function startGame() { 137 | grid = new Array(GRID_WIDTH * 4); 138 | snake = []; 139 | for (var x = 0; x < INITIAL_SNAKE_LENGTH; x++) { 140 | var y = 2; 141 | snake.unshift({x: x, y: y}); 142 | setCellAt(x, y, SNAKE_CELL); 143 | } 144 | currentDirection = RIGHT; 145 | moveQueue = []; 146 | hasMoved = false; 147 | dropFood(); 148 | } 149 | 150 | function updateWorld() { 151 | if (moveQueue.length) { 152 | currentDirection = moveQueue.pop(); 153 | } 154 | 155 | var head = snake[0]; 156 | var tail = snake[snake.length - 1]; 157 | var newX = head.x + currentDirection.x; 158 | var newY = head.y + currentDirection.y; 159 | 160 | var outOfBounds = newX < 0 || newX >= GRID_WIDTH || newY < 0 || newY >= 4; 161 | var collidesWithSelf = cellAt(newX, newY) === SNAKE_CELL 162 | && !(newX === tail.x && newY === tail.y); 163 | 164 | if (outOfBounds || collidesWithSelf) { 165 | endGame(); 166 | startGame(); 167 | return; 168 | } 169 | 170 | var eatsFood = cellAt(newX, newY) === FOOD_CELL; 171 | if (!eatsFood) { 172 | snake.pop(); 173 | setCellAt(tail.x, tail.y, null); 174 | } 175 | 176 | // Advance head after tail so it can occupy the same cell on next tick. 177 | setCellAt(newX, newY, SNAKE_CELL); 178 | snake.unshift({x: newX, y: newY}); 179 | 180 | if (eatsFood) { 181 | dropFood(); 182 | } 183 | } 184 | 185 | function endGame() { 186 | var score = currentScore(); 187 | var maxScore = parseInt(localStorage.maxScore || 0); 188 | if (score > 0 && score > maxScore && hasMoved) { 189 | localStorage.maxScore = score; 190 | localStorage.maxScoreGrid = gridString(); 191 | drawMaxScore(); 192 | showMaxScore(); 193 | } 194 | } 195 | 196 | function drawWorld() { 197 | var hash = '#|' + gridString() + '|[score:' + currentScore() + ']'; 198 | 199 | if (urlRevealed) { 200 | // Use the original game representation on the on-DOM view, as there are no 201 | // escaping issues there. 202 | $('#url').textContent = location.href.replace(/#.*$/, '') + hash; 203 | } 204 | 205 | // Modern browsers escape whitespace characters on the address bar URL for 206 | // security reasons. In case this browser does that, replace the empty Braille 207 | // character with a non-whitespace (and hopefully non-intrusive) symbol. 208 | if (whitespaceReplacementChar) { 209 | hash = hash.replace(/\u2800/g, whitespaceReplacementChar); 210 | } 211 | 212 | history.replaceState(null, null, hash); 213 | 214 | // Some browsers have a rate limit on history.replaceState() calls, resulting 215 | // in the URL not updating at all for a couple of seconds. In those cases, 216 | // location.hash is updated directly, which is unfortunate, as it causes a new 217 | // navigation entry to be created each time, effectively hijacking the user's 218 | // back button. 219 | if (decodeURIComponent(location.hash) !== hash) { 220 | console.warn( 221 | 'history.replaceState() throttling detected. Using location.hash fallback' 222 | ); 223 | location.hash = hash; 224 | } 225 | } 226 | 227 | function gridString() { 228 | var str = ''; 229 | for (var x = 0; x < GRID_WIDTH; x += 2) { 230 | // Unicode Braille patterns are 256 code points going from 0x2800 to 0x28FF. 231 | // They follow a binary pattern where the bits are, from least significant 232 | // to most: ⠁⠂⠄⠈⠐⠠⡀⢀ 233 | // So, for example, 147 (10010011) corresponds to ⢓ 234 | var n = 0 235 | | bitAt(x, 0) << 0 236 | | bitAt(x, 1) << 1 237 | | bitAt(x, 2) << 2 238 | | bitAt(x + 1, 0) << 3 239 | | bitAt(x + 1, 1) << 4 240 | | bitAt(x + 1, 2) << 5 241 | | bitAt(x, 3) << 6 242 | | bitAt(x + 1, 3) << 7; 243 | str += String.fromCharCode(0x2800 + n); 244 | } 245 | return str; 246 | } 247 | 248 | function tickTime() { 249 | // Game speed increases as snake grows. 250 | var start = 125; 251 | var end = 75; 252 | return start + snake.length * (end - start) / grid.length; 253 | } 254 | 255 | function currentScore() { 256 | return snake.length - INITIAL_SNAKE_LENGTH; 257 | } 258 | 259 | function cellAt(x, y) { 260 | return grid[x % GRID_WIDTH + y * GRID_WIDTH]; 261 | } 262 | 263 | function bitAt(x, y) { 264 | return cellAt(x, y) ? 1 : 0; 265 | } 266 | 267 | function setCellAt(x, y, cellType) { 268 | grid[x % GRID_WIDTH + y * GRID_WIDTH] = cellType; 269 | } 270 | 271 | function dropFood() { 272 | var emptyCells = grid.length - snake.length; 273 | if (emptyCells === 0) { 274 | return; 275 | } 276 | var dropCounter = Math.floor(Math.random() * emptyCells); 277 | for (var i = 0; i < grid.length; i++) { 278 | if (grid[i] === SNAKE_CELL) { 279 | continue; 280 | } 281 | if (dropCounter === 0) { 282 | grid[i] = FOOD_CELL; 283 | break; 284 | } 285 | dropCounter--; 286 | } 287 | } 288 | 289 | function changeDirection(newDir) { 290 | var lastDir = moveQueue[0] || currentDirection; 291 | var opposite = newDir.x + lastDir.x === 0 && newDir.y + lastDir.y === 0; 292 | if (!opposite) { 293 | // Process moves in a queue to prevent multiple direction changes per tick. 294 | moveQueue.unshift(newDir); 295 | } 296 | hasMoved = true; 297 | } 298 | 299 | function drawMaxScore() { 300 | var maxScore = localStorage.maxScore; 301 | if (maxScore == null) { 302 | return; 303 | } 304 | 305 | var maxScoreGrid = localStorage.maxScoreGrid; 306 | $('#max-score').innerText = maxScore; 307 | $('#max-score-grid').innerText = maxScoreGrid; 308 | $('#max-score-container').classList.remove('invisible'); 309 | 310 | if (navigator.share) { 311 | $('#share').classList.remove('invisible'); 312 | $('#share').onclick = function (e) { 313 | e.preventDefault(); 314 | shareScore(maxScore, maxScoreGrid); 315 | }; 316 | } 317 | } 318 | 319 | // Expands the high score details if collapsed. Only done when beating the 320 | // highest score, to grab the player's attention. 321 | function showMaxScore() { 322 | if ($('#max-score-container.expanded')) return 323 | $('#max-score-container .expand-btn').click(); 324 | } 325 | 326 | function shareScore(score, grid) { 327 | navigator.share({ 328 | url: $('link[rel=canonical]').href, 329 | text: '|' + grid + '| Got ' + score + 330 | ' points playing this stupid snake game on the browser URL!' 331 | }); 332 | } 333 | 334 | // Super hacky function to pick a suitable character to replace the empty 335 | // Braille character (u+2800) when the browser escapes whitespace on the URL. 336 | // We want to pick a character that's close in width to the empty Braille symbol 337 | // —so the game doesn't stutter horizontally—, and also pick something that's 338 | // not too visually noisy. So we actually measure how wide and how "dark" some 339 | // candidate characters are when rendered by the browser (using a canvas) and 340 | // pick the first that passes both criteria. 341 | function pickWhitespaceReplacementChar() { 342 | var candidates = [ 343 | // U+0ADF is part of the Gujarati Unicode blocks, but it doesn't have an 344 | // associated glyph. For some reason, Chrome renders is as totally blank and 345 | // almost the same size as the Braille empty character, but it doesn't 346 | // escape it on the address bar URL, so this is the perfect replacement 347 | // character. This behavior of Chrome is probably a bug, and might be 348 | // changed at any time, and in other browsers like Firefox this character is 349 | // rendered with an ugly "undefined" glyph, so it'll get filtered out by the 350 | // width or the "blankness" check in either of those cases. 351 | ['૟', 'strange symbols'], 352 | // U+27CB Mathematical Rising Diagonal, not a great replacement for 353 | // whitespace, but is close to the correct size and blank enough. 354 | ['⟋', 'some weird slashes'] 355 | ]; 356 | 357 | var N = 5; 358 | var canvas = document.createElement('canvas'); 359 | var ctx = canvas.getContext('2d'); 360 | ctx.font = '30px system-ui'; 361 | var targetWidth = ctx.measureText(BRAILLE_SPACE.repeat(N)).width; 362 | 363 | for (var i = 0; i < candidates.length; i++) { 364 | var char = candidates[i][0]; 365 | var str = char.repeat(N); 366 | var width = ctx.measureText(str).width; 367 | var similarWidth = Math.abs(targetWidth - width) / targetWidth <= 0.1; 368 | 369 | ctx.clearRect(0, 0, canvas.width, canvas.height); 370 | ctx.fillText(str, 0, 30); 371 | var pixelData = ctx.getImageData(0, 0, width, 30).data; 372 | var totalPixels = pixelData.length / 4; 373 | var coloredPixels = 0; 374 | for (var j = 0; j < totalPixels; j++) { 375 | var alpha = pixelData[j * 4 + 3]; 376 | if (alpha != 0) { 377 | coloredPixels++; 378 | } 379 | } 380 | var notTooDark = coloredPixels / totalPixels < 0.15; 381 | 382 | if (similarWidth && notTooDark) { 383 | return candidates[i]; 384 | } 385 | } 386 | 387 | // Fallback to a safe U+2591 Light Shade. 388 | return ['░', 'some kind of "fog"']; 389 | } 390 | 391 | var $ = document.querySelector.bind(document); 392 | 393 | main(); 394 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | /* Reset */ 2 | html, body, h1, p { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | html { 12 | height: 100%; 13 | font-family: Arial, sans-serif; 14 | color: #222; 15 | line-height: 1.5; 16 | } 17 | 18 | body { 19 | min-height: 100%; 20 | display: flex; 21 | flex-direction: column; 22 | align-items: flex-start; 23 | gap: 4px; 24 | padding: 10px; 25 | } 26 | 27 | a { 28 | color: #3473ee; 29 | } 30 | 31 | .controls { 32 | display: grid; 33 | grid-template-areas: ". up ." 34 | "left down right"; 35 | /* Limit the size of the controls by the viewport size. */ 36 | width: 75vmin; 37 | height: 50vmin; 38 | margin: 10px auto; 39 | } 40 | 41 | #up { grid-area: up; } 42 | #down { grid-area: down; } 43 | #left { grid-area: left; } 44 | #right { grid-area: right; } 45 | 46 | .controls button { 47 | color: #888; 48 | font-size: 8vmin; 49 | background: none; 50 | border: 2px solid; 51 | margin: 2px; 52 | border-radius: 5vmin; 53 | } 54 | 55 | .controls button:focus { 56 | outline: none; 57 | } 58 | 59 | @media (min-width: 10cm) and (min-height: 10cm) { 60 | /* Avoid controls getting too big on larger touch devices. */ 61 | .controls { 62 | position: absolute; 63 | width: 7.5cm; 64 | height: 5cm; 65 | bottom: 1.5cm; 66 | right: 1.5cm; 67 | } 68 | .controls button { 69 | font-size: 0.8cm; 70 | border-radius: 0.5cm; 71 | } 72 | } 73 | 74 | #url, 75 | #max-score-grid { 76 | background: #8883; 77 | padding: 2px; 78 | border-radius: 3px; 79 | } 80 | 81 | #share { 82 | display: inline-block; 83 | } 84 | 85 | footer { 86 | margin-top: auto; 87 | font-size: 0.9rem; 88 | } 89 | 90 | .invisible { 91 | display: none !important; 92 | } 93 | 94 | .hidden { 95 | visibility: hidden; 96 | } 97 | 98 | :root.touch .no-touch-only, 99 | :root:not(.touch) .touch-only { 100 | display: none; 101 | } 102 | 103 | .expandable { 104 | position: relative; 105 | } 106 | 107 | .expand-btn, 108 | .collapse-btn { 109 | background: none; 110 | border: none; 111 | padding: 0; 112 | font: inherit; 113 | font-weight: bold; 114 | cursor: pointer; 115 | width: 1rem; 116 | } 117 | 118 | .expand-btn, 119 | .expandable { 120 | transition: transform, opacity; 121 | transition-duration: .4s; 122 | } 123 | 124 | .expandable { 125 | display: inline-block; 126 | position: relative; 127 | height: 1.5rem; 128 | transform: translateX(-100%); 129 | /* Clear body padding so it doesn't show on the left of the expand-btn */ 130 | padding-right: 10px; 131 | } 132 | 133 | .expand-btn { 134 | position: absolute; 135 | right: 0; 136 | top: 0; 137 | transform: translateX(100%); 138 | opacity: 1; 139 | } 140 | 141 | .help-toggle { 142 | color: #0bc3ff;; 143 | } 144 | 145 | .high-score-toggle { 146 | color: #ff8c0b; 147 | } 148 | 149 | .collapse-btn { 150 | color: #aaa; 151 | } 152 | 153 | .expandable.expanded { 154 | height: auto; 155 | transform: none; 156 | } 157 | 158 | .expandable.expanded .expand-btn { 159 | opacity: 0; 160 | } 161 | 162 | @media (prefers-color-scheme: dark) { 163 | html { 164 | background: #222; 165 | color: #eee; 166 | } 167 | } 168 | 169 | /* Icon font styles copied from Fontello. */ 170 | @font-face { 171 | font-family: 'icons'; 172 | src: url('icons.woff2?40046441') format('woff2'); 173 | font-weight: normal; 174 | font-style: normal; 175 | } 176 | [class^="icon-"]:before, [class*=" icon-"]:before { 177 | font-family: "icons"; 178 | font-style: normal; 179 | font-weight: normal; 180 | speak: none; 181 | display: inline-block; 182 | text-decoration: inherit; 183 | width: 1em; 184 | margin-right: .2em; 185 | text-align: center; 186 | /* For safety - reset parent styles, that can break glyph codes*/ 187 | font-variant: normal; 188 | text-transform: none; 189 | /* Animation center compensation - margins should be symmetric */ 190 | /* remove if not needed */ 191 | margin-left: .2em; 192 | /* Font smoothing. That was taken from TWBS */ 193 | -webkit-font-smoothing: antialiased; 194 | -moz-osx-font-smoothing: grayscale; 195 | } 196 | 197 | .icon-share:before { content: '\e811'; } 198 | --------------------------------------------------------------------------------