It's Dรญa de los Muertos ๐ and the marigolds are out, but the sugar skull has been scrambled into pieces! Slide the tiles back into place to rebuild the calavera: tap or click a tile next to the gap, or use the arrow keys. Every puzzle you solve adds 10 seconds to the clock, and the next one comes out even more mixed up. When the time runs out, the fiesta is over. How many calaveras can you rebuild before the last second?
.cal-game {
text-align: center;
}
.cal-game .instructions {
max-width: 500px;
margin: 0 auto 15px;
font-size: 1em;
color: #555;
}
.cal-info {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 10px;
font-size: 1.2em;
}
.cal-board {
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
width: 300px;
max-width: 100%;
aspect-ratio: 1 / 1;
margin: 0 auto;
padding: 4px;
box-sizing: border-box;
background: #3b1d5e;
border: 3px solid #f28c28;
border-radius: 12px;
touch-action: manipulation;
}
.cal-tile {
border: none;
border-radius: 6px;
padding: 0;
background-repeat: no-repeat;
background-size: 300% 300%;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: transform 0.08s;
}
.cal-tile:active {
transform: scale(0.96);
}
.cal-tile.cal-gap {
background: transparent;
cursor: default;
}
.cal-tile:disabled {
cursor: default;
}
.cal-board.cal-solved {
box-shadow: 0 0 24px 6px rgba(242, 140, 40, 0.6);
}
.cal-message {
margin-top: 15px;
font-weight: bold;
min-height: 1.5em;
}
.cal-btn {
margin-top: 15px;
padding: 10px 20px;
font-size: 1em;
background-color: #e0457b;
color: #fff;
font-weight: bold;
border: none;
border-radius: 5px;
cursor: pointer;
}
.cal-btn:hover {
background-color: #ef5b8f;
}
.cal-btn:disabled {
background-color: #ccc;
color: #666;
cursor: not-allowed;
}
<div class="cal-game">
<h1>๐ Calavera Slide ๐ผ</h1>
<div class="instructions">
Slide the tiles to rebuild the sugar skull before time runs out. Tap or click a tile next to the gap, or use the arrow keys. Every solved puzzle adds 10 seconds, and the next one is more scrambled!
</div>
<div class="cal-info">
<span>Solved: <span id="calScore">0</span></span>
<span>Time: <span id="calTime">90</span>s</span>
<span>Best: <span id="calBest">0</span></span>
</div>
<div class="cal-board" id="calBoard"></div>
<div class="cal-message" id="calMessage">Press "Start" to celebrate Dรญa de los Muertos...</div>
<button class="cal-btn" id="calStart">Start</button>
</div>
const calBoard = document.getElementById('calBoard');
const calScoreEl = document.getElementById('calScore');
const calTimeEl = document.getElementById('calTime');
const calBestEl = document.getElementById('calBest');
const calMessageEl = document.getElementById('calMessage');
const calStartBtn = document.getElementById('calStart');
const CAL_SIZE = 3;
const CAL_START_SECONDS = 90;
const CAL_BONUS_SECONDS = 10;
let calTiles = []; // calTiles[position] = tile number (0..7), or -1 for the gap
let calPicture = '';
let calScore = 0;
let calTimeLeft = CAL_START_SECONDS;
let calRunning = false;
let calLastTime = 0;
let calFrame = null;
let calRound = 0;
let calLocked = false;
function calReadBest() {
try {
return parseInt(localStorage.getItem('calaveraSlideBest') || '0', 10) || 0;
} catch (e) {
return 0;
}
}
function calSaveBest(value) {
try {
localStorage.setItem('calaveraSlideBest', String(value));
} catch (e) {
// storage blocked: the best score only lasts for this visit
}
}
let calBest = calReadBest();
calBestEl.textContent = calBest;
function calSetShare(visible) {
// The share buttons come after the game in the page, so look them up each time.
const share = document.querySelector('.share-buttons');
if (share) {
share.style.display = visible ? 'flex' : 'none';
}
}
function calSetHidden(value) {
const hidden = document.getElementById('hiddenScore');
if (hidden) {
hidden.value = value;
}
}
// Draws the sugar skull once on a canvas; each tile shows its part of it.
function calDrawPicture() {
const c = document.createElement('canvas');
c.width = 300;
c.height = 300;
const g = c.getContext('2d');
const bg = g.createRadialGradient(150, 150, 20, 150, 150, 210);
bg.addColorStop(0, '#7b3fb0');
bg.addColorStop(1, '#2a1245');
g.fillStyle = bg;
g.fillRect(0, 0, 300, 300);
// Marigold petals around the edge
const petals = ['#f28c28', '#f6b93b', '#e0457b'];
for (let i = 0; i < 18; i++) {
const a = (i / 18) * Math.PI * 2;
g.fillStyle = petals[i % 3];
g.beginPath();
g.arc(150 + Math.cos(a) * 128, 150 + Math.sin(a) * 128, 14, 0, Math.PI * 2);
g.fill();
}
// Skull
g.fillStyle = '#fbf3e4';
g.beginPath();
g.ellipse(150, 135, 78, 82, 0, 0, Math.PI * 2);
g.fill();
g.fillRect(105, 185, 90, 50);
g.beginPath();
g.ellipse(150, 235, 45, 14, 0, 0, Math.PI * 2);
g.fill();
// Eyes with flower centres
[[118, 135], [182, 135]].forEach(function (p) {
g.fillStyle = '#2a1245';
g.beginPath();
g.arc(p[0], p[1], 24, 0, Math.PI * 2);
g.fill();
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
g.fillStyle = i % 2 ? '#1fa39a' : '#e0457b';
g.beginPath();
g.arc(p[0] + Math.cos(a) * 12, p[1] + Math.sin(a) * 12, 7, 0, Math.PI * 2);
g.fill();
}
g.fillStyle = '#f6b93b';
g.beginPath();
g.arc(p[0], p[1], 6, 0, Math.PI * 2);
g.fill();
});
// Nose
g.fillStyle = '#2a1245';
g.beginPath();
g.moveTo(150, 160);
g.lineTo(140, 182);
g.lineTo(160, 182);
g.closePath();
g.fill();
// Smile and teeth
g.strokeStyle = '#2a1245';
g.lineWidth = 4;
g.beginPath();
g.moveTo(112, 205);
g.quadraticCurveTo(150, 228, 188, 205);
g.stroke();
for (let x = 122; x <= 178; x += 14) {
g.beginPath();
g.moveTo(x, 207);
g.lineTo(x, 222);
g.stroke();
}
// Forehead flower
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 2 - Math.PI / 2;
g.fillStyle = '#f28c28';
g.beginPath();
g.arc(150 + Math.cos(a) * 11, 82 + Math.sin(a) * 11, 8, 0, Math.PI * 2);
g.fill();
}
g.fillStyle = '#e0457b';
g.beginPath();
g.arc(150, 82, 6, 0, Math.PI * 2);
g.fill();
return c.toDataURL('image/png');
}
function calRender() {
calBoard.innerHTML = '';
calTiles.forEach(function (tile, position) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'cal-tile';
if (tile === -1) {
btn.className += ' cal-gap';
btn.disabled = true;
btn.setAttribute('aria-label', 'Empty space');
} else {
const col = tile % CAL_SIZE;
const row = Math.floor(tile / CAL_SIZE);
btn.style.backgroundImage = 'url(' + calPicture + ')';
btn.style.backgroundPosition = (col * 50) + '% ' + (row * 50) + '%';
btn.setAttribute('aria-label', 'Tile ' + (tile + 1));
btn.disabled = !calRunning || calLocked;
}
btn.addEventListener('click', function () {
calTryMove(position);
});
calBoard.appendChild(btn);
});
}
function calGapPosition() {
return calTiles.indexOf(-1);
}
function calNeighbours(position) {
const row = Math.floor(position / CAL_SIZE);
const col = position % CAL_SIZE;
const result = [];
if (row > 0) result.push(position - CAL_SIZE);
if (row < CAL_SIZE - 1) result.push(position + CAL_SIZE);
if (col > 0) result.push(position - 1);
if (col < CAL_SIZE - 1) result.push(position + 1);
return result;
}
function calIsSolved() {
for (let i = 0; i < CAL_SIZE * CAL_SIZE - 1; i++) {
if (calTiles[i] !== i) return false;
}
return true;
}
// Shuffles by playing random legal moves from the solved state, so every puzzle can be solved.
function calShuffle(moves) {
calTiles = [0, 1, 2, 3, 4, 5, 6, 7, -1];
let previous = -1;
for (let i = 0; i < moves; i++) {
const gap = calGapPosition();
const options = calNeighbours(gap).filter(function (p) { return p !== previous; });
const pick = options[Math.floor(Math.random() * options.length)];
calTiles[gap] = calTiles[pick];
calTiles[pick] = -1;
previous = gap;
}
if (calIsSolved()) {
calShuffle(moves + 1);
}
}
function calTryMove(position) {
if (!calRunning || calLocked) return;
const gap = calGapPosition();
if (calNeighbours(gap).indexOf(position) === -1) return;
calTiles[gap] = calTiles[position];
calTiles[position] = -1;
calRender();
if (calIsSolved()) {
calSolved();
}
}
function calSolved() {
calScore++;
calScoreEl.textContent = calScore;
calSetHidden(calScore);
calTimeLeft += CAL_BONUS_SECONDS;
calMessageEl.textContent = '๐ผ ยกBien hecho! +' + CAL_BONUS_SECONDS + 's. Here comes a harder one...';
calLocked = true;
calBoard.classList.add('cal-solved');
calRender();
setTimeout(function () {
calBoard.classList.remove('cal-solved');
calNextRound();
}, 900);
}
function calNextRound() {
calRound++;
// Each puzzle is scrambled a little more than the last.
calShuffle(8 + calRound * 6);
calLocked = false;
calMessageEl.textContent = 'Puzzle ' + calRound + ': rebuild the calavera!';
calRender();
}
function calTick(now) {
if (!calRunning) return;
const delta = (now - calLastTime) / 1000;
calLastTime = now;
if (!calLocked) {
calTimeLeft -= delta;
}
if (calTimeLeft <= 0) {
calTimeLeft = 0;
calTimeEl.textContent = '0';
calGameOver();
return;
}
calTimeEl.textContent = String(Math.ceil(calTimeLeft));
calFrame = requestAnimationFrame(calTick);
}
function calGameOver() {
calRunning = false;
cancelAnimationFrame(calFrame);
calRender();
const word = calScore === 1 ? ' puzzle' : ' puzzles';
if (calScore > calBest) {
calBest = calScore;
calSaveBest(calBest);
calBestEl.textContent = calBest;
calMessageEl.textContent = 'โฐ Time is up! New best: ' + calScore + word + ' solved ๐';
} else {
calMessageEl.textContent = 'โฐ Time is up! You solved ' + calScore + word + '.';
}
calStartBtn.disabled = false;
calStartBtn.textContent = 'Play again';
calSetShare(true);
}
function calStart() {
calScore = 0;
calRound = 0;
calTimeLeft = CAL_START_SECONDS;
calScoreEl.textContent = '0';
calTimeEl.textContent = String(CAL_START_SECONDS);
calSetHidden(0);
calSetShare(false);
calStartBtn.disabled = true;
calRunning = true;
calNextRound();
calLastTime = performance.now();
calFrame = requestAnimationFrame(calTick);
}
document.addEventListener('keydown', function (e) {
if (!calRunning || calLocked) return;
const gap = calGapPosition();
const row = Math.floor(gap / CAL_SIZE);
const col = gap % CAL_SIZE;
let from = -1;
// An arrow moves the tile on the opposite side of the gap in that direction.
if (e.key === 'ArrowUp' && row < CAL_SIZE - 1) from = gap + CAL_SIZE;
if (e.key === 'ArrowDown' && row > 0) from = gap - CAL_SIZE;
if (e.key === 'ArrowLeft' && col < CAL_SIZE - 1) from = gap + 1;
if (e.key === 'ArrowRight' && col > 0) from = gap - 1;
if (from !== -1) {
e.preventDefault();
calTryMove(from);
}
});
calPicture = calDrawPicture();
calTiles = [0, 1, 2, 3, 4, 5, 6, 7, -1];
calRender();
calStartBtn.addEventListener('click', calStart);