Skip to content

Instantly share code, notes, and snippets.

@leobetosouza
Created April 16, 2024 22:08
Show Gist options
  • Save leobetosouza/ded6b8d784ecdfe8b8aa14db29440463 to your computer and use it in GitHub Desktop.
Save leobetosouza/ded6b8d784ecdfe8b8aa14db29440463 to your computer and use it in GitHub Desktop.
Simple JS Snake Game on one file
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SNAKE GAME</title>
<style>
body {
margin: 0;
padding: 0;
font-family: monospace;
}
#snake_game {
background: black;
color: white;
padding: 10px;
width: fit-content;
margin: 0 auto;
display: flex;
flex-direction: column;
#meta, #footer {
display: flex;
justify-content: space-between;
padding: 0 5px;
}
#meta {
margin-bottom: 3px;
.key-controls {
display: flex;
flex-direction: column;
align-items: end;
}
}
#footer {
margin-top: 5px;
}
.record-wrapper {
display: none;
&.active {
display: initial;
}
}
.points {
font-weight: bold;
&.has-overcome {
color: lightgreen;
}
}
#board {
border: 5px solid white;
section {
padding: 0 0 1px 1px;
border: 1px solid white;
> div {
display: flex;
justify-content: center;
}
}
span {
display: block;
border: 1px solid white;
margin: 1px 1px 0 0;
font-size: 0;
padding: 1.2vw;
box-sizing: border-box;
}
.snake,
.food {
border: 1px solid black;
}
.poop {
background-image: linear-gradient(to bottom right, white 0, white 50%);
background-size: 50% 50%;
background-position: center center;
background-repeat: no-repeat;
&.vanishing {
background-size: 70% 70%;
}
}
.food {
background: red;
border-radius: 50%;
}
.obstacle {
background: white;
}
.snake {
background: lightgreen;
&.head {
background-repeat: no-repeat;
&.dir-e {
background-image:
linear-gradient(to top left, red 50%, transparent 0),
linear-gradient(to top right, transparent 50%, red 0);
background-size: 100% 50%;
background-position: top, bottom;
}
&.dir-w {
background-image:
linear-gradient(to right top, red 50%, transparent 0),
linear-gradient(to left top, transparent 50%, red 0);
background-size: 100% 50%;
background-position: top, bottom;
}
&.dir-n {
background-image:
linear-gradient(to left bottom, red 50%, transparent 0),
linear-gradient(to right bottom, red 50%, transparent 0);
background-size: 50% 100%;
background-position: left, right;
}
&.dir-s {
background-image:
linear-gradient(to bottom right, transparent 50%, red 0),
linear-gradient(to top right, red 50%, transparent 0);
background-size: 50% 100%;
background-position: left, right;
}
}
&.tail {
background-color: black;
&.dir-w {
background-image:
linear-gradient(to top left, lightgreen 50%, transparent 0),
linear-gradient(to top right, transparent 50%, lightgreen 0);
background-size: 100% 50%;
background-position: top, bottom;
}
&.dir-e {
background-image:
linear-gradient(to right top, lightgreen 50%, transparent 0),
linear-gradient(to left top, transparent 50%, lightgreen 0);
background-size: 100% 50%;
background-position: top, bottom;
}
&.dir-s {
background-image:
linear-gradient(to left bottom, lightgreen 50%, transparent 0),
linear-gradient(to right bottom, lightgreen 50%, transparent 0);
background-size: 50% 100%;
background-position: left, right;
}
&.dir-n {
background-image:
linear-gradient(to bottom right, transparent 50%, lightgreen 0),
linear-gradient(to top right, lightgreen 50%, transparent 0);
background-size: 50% 100%;
background-position: left, right;
}
}
&.food-eaten {
background-image: linear-gradient(to bottom right, red 0, red 50%);
background-size: 50% 50%;
background-position: center center;
background-repeat: no-repeat;
}
}
}
.btn-false {
display: inline-block;
font-weight: bold;
border: 1px solid white;
border-radius: 3px;
padding: 1px 3px;
margin-bottom: 2px;
.active {
display: none;
}
.inactive {
display: unset;
}
}
&[data-gamemode="infinity"] {
#board {
border-color: black;
}
#infinity_control {
.active {
display: unset;
}
.inactive {
display: none;
}
}
}
&[data-controlmode="relative"] {
#relative_control {
.active {
display: unset;
}
.inactive {
display: none;
}
}
}
&[data-gamestate="ready"] {
.pre-game-controls {
display: initial;
}
.reset-control {
display: none;
}
.label-on-ready {
display: initial;
}
.label-on-started,
.label-on-paused,
.label-on-ended {
display: none;
}
}
&[data-gamestate="started"] {
.pre-game-controls {
display: none;
}
.reset-control {
display: initial;
}
.label-on-started {
display: initial;
}
.label-on-ready,
.label-on-paused,
.label-on-ended {
display: none;
}
}
&[data-gamestate="paused"] {
.pre-game-controls {
display: none;
}
.reset-control {
display: initial;
}
.label-on-paused {
display: initial;
}
.label-on-ready,
.label-on-started,
.label-on-ended {
display: none;
}
}
&[data-gamestate="ended"] {
.pre-game-controls {
display: none;
}
.reset-control {
display: initial;
}
.label-on-ended {
display: initial;
}
.label-on-ready,
.label-on-started,
.label-on-paused {
display: none;
}
#board {
border-color: red !important;
.snake {
border-color: red !important;
}
}
}
#controls {
text-align: center;
padding-top: 10px;
}
}
</style>
</head>
<body>
<main id="snake_game" data-gamestate="ready">
<aside id="meta">
<span>
<span class="points">0</span>
<span class="record-wrapper">/ <span class="record">0</span></span>
</span>
<span class="key-controls">
<span class="btn-false action-control">
enter:
<span class="label-on-ready">start game</span>
<span class="label-on-started">pause game</span>
<span class="label-on-paused">resume game</span>
<span class="label-on-ended">reset game</span>
</span>
<span class="btn-false reset-control">esc: reset game</span>
<span class="pre-game-controls">
<span id="infinity_control" class="btn-false">i: <span class="active">open</span><span class="inactive">walled</span> grid</span>
<span id="relative_control" class="btn-false">u: <span class="active">relative</span><span class="inactive">absolute</span> controls</span>
</span>
</span>
</aside>
<div id="board"></div>
<aside id="footer">
<span>Snake Game JS by @leobetosouza</span>
<span>
<span class="walled-record">0</span> | <span class="infinity-record">0</span>i
</span>
</aside>
</main>
</body>
<script>
let walledRecord = +localStorage.getItem('snake.walledRecord') || 0;
let infinityRecord = +localStorage.getItem('snake.infinityRecord') || 0;
let infinityGameMode = Boolean(+localStorage.getItem('snake.isInfinityGame'));
let relativeControlsMode = Boolean(+localStorage.getItem('snake.isRelativeControls'));
const board = document.createElement('section');
const REDRAW_TIME = 200;
const BOARD_WIDTH = 27;
const BOARD_HEIGHT = 21;
const positions = Array.from(
{ length: BOARD_HEIGHT },
(_, y) => Array.from(
{ length: BOARD_WIDTH },
(_, x) => ({ x, y })
)
);
let defaultFoodValue = 5;
let eatingCount = 0;
let defaultFoodsToPlace = 3;
let direction = 'E';
const snake = [{ x: 0, y: 0, direction }];
const foods = [];
const movementBuffer = [];
const poops = [];
const obstacles = [];
const oppositeDirection = {
N : 'S',
S : 'N',
E : 'W',
W : 'E'
};
const relativeDirections = {
N: {
'arrowright': 'E',
'arrowleft': 'W'
},
S: {
'arrowright': 'W',
'arrowleft': 'E'
},
E: {
'arrowright': 'S',
'arrowleft': 'N'
},
W: {
'arrowright': 'N',
'arrowleft': 'S'
}
};
let willReset = false;
let hasGameOver = false;
let initialized = false;
let paused = false;
let nextHead = null;
window.addEventListener('DOMContentLoaded', prepareGame);
window.addEventListener('keydown', handleInput);
const gameWrapper = document.getElementById('snake_game');
const pointsCounters = gameWrapper.getElementsByClassName('points');
const recordCounters = gameWrapper.getElementsByClassName('record');
const recordWrappers = gameWrapper.getElementsByClassName('record-wrapper');
const walledRecordCounters = gameWrapper.getElementsByClassName('walled-record');
const infinityRecordCounters = gameWrapper.getElementsByClassName('infinity-record');
function resetGame() {
gameWrapper.setAttribute('data-gamestate', 'ready');
direction = 'E';
snake.length = 0;
snake.push({ x: 0, y: 0, direction });
renderPoints();
movementBuffer.length = 0;
poops.length = 0;
obstacles.length = 0;
foods.length = 0;
nextHead = null;
direction = 'E';
paused = false;
initialized = false;
hasGameOver = false;
willReset = false;
drawBoard();
}
function prepareGame() {
document.getElementById('board').appendChild(board);
setGridGameMode();
setControlMode();
renderRecords();
drawBoard();
}
function initGame() {
initialized = true;
gameWrapper.setAttribute('data-gamestate', 'started');
gameLoop();
}
async function gameLoop() {
do {
if (paused) {
console.warn("PAUSOU");
await delay(REDRAW_TIME/2);
continue;
}
renderPoints();
createNextHead();
if (!foods.length) createFood();
drawBoard();
moveSnake();
cleanPoop();
if (checkGameOver()) {
gameOver();
break;
}
if (willReset) {
resetGame();
break;
}
await delay(REDRAW_TIME);
} while(true);
}
function drawBoard() {
board.innerHTML = '';
const head = snake[0];
const tail = snake[snake.length - 1];
const grid = positions.reduce((frag, lines, y, linesLimit) => {
const gridLines = lines.reduce((line, _, x, columnsLimit) => {
const cell = document.createElement('span');
const cellClasses = cell.classList;
cell.innerHTML = '&nbsp;';
const pos = { x, y };
if (includes(foods, pos))
cellClasses.add('food');
else {
const bodySegment = snake.find((c) => equals(c, pos));
if (bodySegment) {
cellClasses.add('snake');
if (equals(head, bodySegment))
cellClasses.add('head', `dir-${ head.direction.toLowerCase() }`);
else if (equals(tail, bodySegment))
cellClasses.add('tail', `dir-${ oppositeDirection[tail.direction].toLowerCase() }`);
else if (bodySegment.hasFood)
cellClasses.add('food-eaten');
}
}
const poop = poops.find((p) => equals(p, pos));
if (poop) {
cellClasses.add('poop');
if (poop.timeToVanish < REDRAW_TIME * 6) {
cellClasses.add('vanishing');
}
}
if (includes(obstacles, pos))
cellClasses.add('obstacle');
if (x === 0)
cellClasses.add('limit', 'top-limit');
if (y === 0)
cellClasses.add('limit', 'left-limit');
if (x === linesLimit.length - 1)
cellClasses.add('limit', 'bottom-limit');
if (y === columnsLimit.length - 1)
cellClasses.add('limit', 'right-limit');
line.appendChild(cell);
return line;
}, document.createElement('div'));
frag.appendChild(gridLines);
return frag;
}, document.createDocumentFragment());
board.appendChild(grid);
}
function createFood() {
for (let i = 0; i < defaultFoodsToPlace; i++) {
const validPositons = positions
.reduce((acc, arr) => ([
...acc,
...arr.filter((o) => !includes([nextHead, ...snake, ...foods, ...obstacles], o))
]), []);
const food = validPositons[getRandomInt(0, validPositons.length-1)];
food.value = defaultFoodValue;
removePoop(food);
foods.push(food);
}
}
function createNextHead() {
nextHead = { ...snake[0], hasFood: false };
let nextDirection = movementBuffer.length ? movementBuffer.shift() : null;
if (nextDirection === oppositeDirection[direction])
nextDirection = null;
if (nextDirection)
direction = nextDirection;
nextHead.direction = direction;
switch (direction) {
case 'N': nextHead.y--; break;
case 'S': nextHead.y++; break;
case 'E': nextHead.x++; break;
case 'W': nextHead.x--; break;
}
if (infinityGameMode) {
if (nextHead.x === -1) nextHead.x = BOARD_WIDTH-1;
if (nextHead.y === -1) nextHead.y = BOARD_HEIGHT-1;
if (nextHead.x === BOARD_WIDTH) nextHead.x = 0;
if (nextHead.y === BOARD_HEIGHT) nextHead.y = 0;
}
}
function moveSnake() {
removePoop(nextHead);
if (eatFood(nextHead))
nextHead.hasFood = true;
else if (eatingCount)
eatingCount--;
else
createPoop(snake.pop());
snake.unshift(nextHead);
}
function eatFood(pos) {
const idx = foods.findIndex((f) => equals(f, pos));
if (idx === -1) return false;
const food = foods[idx];
eatingCount += food.value;
foods.splice(idx, 1);
return true;
}
function createPoop(poop) {
if (snake.length > 1 && poop.hasFood) {
const { x, y } = poop;
poops.push({ x, y, timeToVanish: REDRAW_TIME * Math.min(BOARD_WIDTH, BOARD_HEIGHT) * 5 });
}
}
function cleanPoop() {
if (poops.length > 10)
removePoop(poops[0]);
for (const poop of poops) {
poop.timeToVanish -= REDRAW_TIME;
if (!poop.timeToVanish) {
removePoop(poop);
obstacles.push(poop);
}
}
}
function removePoop(pos) {
const idx = poops.findIndex((c) => equals(c, pos));
if (idx > -1) poops.splice(idx, 1);
}
function checkGameOver() {
const head = snake[0];
return head.x === -1 || head.y === -1 ||
head.x === BOARD_WIDTH || head.y === BOARD_HEIGHT ||
includes(snake.slice(3), head) ||
includes(obstacles, head);
}
function renderPoints() {
const points = snake.length - 1;
const record = infinityGameMode ? infinityRecord : walledRecord;
const hasOvercomeRecord = points > record;
for (const counter of pointsCounters) {
counter.innerText = points;
if (hasOvercomeRecord) counter.classList.add('has-overcome');
}
}
function renderRecords() {
const record = infinityGameMode ? infinityRecord : walledRecord;
for (const counter of recordCounters)
counter.innerText = record;
for (const counter of recordCounters)
counter.classList.remove('has-overcome');
for (const wrapper of recordWrappers)
wrapper.classList.toggle('active', record > 0);
for (const counter of infinityRecordCounters)
counter.innerText = infinityRecord;
for (const counter of walledRecordCounters)
counter.innerText = walledRecord;
}
function setRecord() {
const points = snake.length - 1;
const previousRecord = infinityGameMode ? infinityRecord : walledRecord;
if (points > previousRecord) {
if (infinityGameMode) {
infinityRecord = points;
localStorage.setItem('snake.infinityRecord', infinityRecord);
} else {
walledRecord = points;
localStorage.setItem('snake.walledRecord', walledRecord);
}
renderRecords();
}
}
function togglePause() {
paused = !paused;
gameWrapper.setAttribute('data-gamestate', paused ? 'paused' : 'started');
}
function handlePlayPause() {
if (hasGameOver) {
resetGame();
return;
}
if (!initialized) {
initGame();
return;
}
togglePause();
}
function toggleGameMode() {
infinityGameMode = !infinityGameMode;
setGridGameMode();
localStorage.setItem('snake.isInfinityGame', infinityGameMode ? 1 : 0);
renderRecords();
}
function setGridGameMode() {
gameWrapper.setAttribute('data-gamemode', infinityGameMode ? 'infinity' : 'walled');
}
function toggleControlMode() {
relativeControlsMode = !relativeControlsMode;
setControlMode();
localStorage.setItem('snake.isRelativeControls', relativeControlsMode ? 1 : 0);
}
function setControlMode() {
gameWrapper.setAttribute('data-controlmode', relativeControlsMode ? 'relative' : 'absolute');
}
function handleInput(e) {
switch (e.key.toLowerCase()) {
case 'enter':
case 'p':
handlePlayPause();
break;
case 'escape':
willReset = true;
break;
case 'i':
if (!initialized) toggleGameMode();
break;
case 'u':
if (!initialized) toggleControlMode();
break;
case 'arrowup':
case 'w':
if (!relativeControlsMode) {
if (isPlaying()) movementBuffer.push('N');
break;
}
case 'arrowdown':
case 's':
if (!relativeControlsMode) {
if (isPlaying()) movementBuffer.push('S');
break;
}
case 'arrowright':
case 'd':
if (isPlaying()) {
if (relativeControlsMode)
movementBuffer.push(relativeDirections[direction]['arrowright']);
else
movementBuffer.push('E');
}
break;
case 'arrowleft':
case 'a':
if (isPlaying()) {
if (relativeControlsMode)
movementBuffer.push(relativeDirections[direction]['arrowleft']);
else
movementBuffer.push('W');
}
break;
default:
return;
}
e.preventDefault();
}
function isPlaying() {
return initialized && !paused && !hasGameOver;
}
function gameOver() {
hasGameOver = true;
gameWrapper.setAttribute('data-gamestate', 'ended');
drawBoard();
setRecord();
console.warn('GAME OVER');
}
function equals(o1, o2) {
return o1.x === o2.x && o1.y === o2.y;
}
function includes(arr, obj) {
return arr.findIndex((c) => equals(obj, c)) !== -1;
}
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
async function delay(ms) {
return new Promise(resolve => setTimeout(() => resolve(), ms));
}
</script>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment