---
title: "Slide & Sorcery"
canonical: "https://presque.cool/projects/slide-sorcery/"
description: "Un dungeon crawler sur une grille 4x4 inspiré de 2048. Chaque glissement déclenche du combat, des combos et des décisions de survie."
---

<div data-lang="fr">

## Le pitch

Slide & Sorcery est un dungeon crawler sur grille 4x4. Un swipe déplace toutes les tuiles d'un coup : explosions, contre-attaques, combos s'enchaînent plus vite qu'on ne les anticipe. On n'orchestre pas chaque effet, on oriente le chaos. Quand un run se termine, l'or accumulé débloque une bénédiction pour le suivant.

**Jouer** : [presque.cool/slide-sorcery/](https://presque.cool/slide-sorcery/)

<div class="quick-overview">
  <div class="overview-item">
    <span class="overview-label">Durée</span>
    <span class="overview-value">~3 semaines</span>
  </div>
  <div class="overview-item">
    <span class="overview-label">Période</span>
    <span class="overview-value">Mars 2026</span>
  </div>
  <div class="overview-item">
    <span class="overview-label">Stack</span>
    <div class="tech-tags">
      <span class="tech-tag">React</span>
      <span class="tech-tag">TypeScript</span>
      <span class="tech-tag">Zustand</span>
      <span class="tech-tag">PWA</span>
    </div>
  </div>
  <div class="overview-item">
    <span class="overview-label">Statut</span>
    <span class="overview-value accent">✓ En production</span>
  </div>
</div>

---

## Pourquoi ce projet

2048 et Threes reposent sur une mécanique simple : glisser des tuiles dans une direction, tout ce qui peut fusionner fusionne. L'idée était de garder ce geste mais de le pousser ailleurs.

Des ennemis à la place des chiffres. Des armes, des sorts, des bombes. Dans 2048, tu sais exactement ce qu'il va se passer avant de swiper. Ici, pas vraiment. Tu slides, des choses explosent, une chaîne se déclenche, un ennemi contre-attaque. Le jeu reste lisible, mais il déborde — et c'est là que c'est intéressant.

---

## Le moteur de jeu

La première version gérait les collisions directement dans le store Zustand. Ça fonctionnait, mais la logique de jeu et les états de présentation étaient entremêlés. Une refonte a séparé les deux couches proprement.

`src/engine/` ne contient que du TypeScript pur, sans import React. `gameLogic.ts` et `resolveCollision.ts` prennent un état en entrée et retournent le suivant — des fonctions pures. Zustand ne fait que stocker le résultat et nourrir les composants.

Cette séparation a rendu les ajouts de mécaniques beaucoup moins risqués. Chaque nouveau type de tuile (checkpoint, armes, boucliers) s'est branché sur le moteur sans toucher au rendu.

---

## La boucle roguelite

Entre deux runs, l'or collecté s'accumule dans un coffre persistant. Avec 50 pièces, le joueur peut débloquer une bénédiction avant de lancer le run suivant.

4 bénédictions disponibles :

- **Bombe de départ** : une bombe supplémentaire dès le premier tour.
- **Épée de départ** : une arme disponible immédiatement.
- **+1 PV max** : plafond de santé relevé à 4.
- **+10% combo** : multiplicateur de score pour les combos.

Aucune progression permanente : la bénédiction s'applique au run en cours uniquement. Le jeu reste court et équilibré sans persistance complexe.

---

## Combos et pity system

C'est là que le jeu tient ou se casse.

**Combos** : éliminer 2 menaces ou plus en un seul coup déclenche un combo. Des combos consécutifs forment un streak. À x3 : bonus de score. À x5 : or bonus. La récompense est exponentielle, la perte immédiate : un coup sans élimination casse tout.

**Pity system** : les spawns de nourriture et de pièces sont aléatoires, mais le moteur surveille le nombre de tours depuis le dernier spawn de chaque type. Après 3 tours sans nourriture, la probabilité d'en voir apparaître augmente automatiquement. Idem pour les pièces. Un anti-streak similaire existe pour les crânes : trop d'ennemis consécutifs amortit la prochaine vague.

Le résultat : le jeu reste difficile sans devenir arbitrairement injuste. Un joueur à court de ressources en voit arriver un peu plus vite, sans que ce soit affiché ou annoncé.

---

## Audio procédural

Aucun fichier audio dans le projet. Chaque son est synthétisé en temps réel via la Web Audio API : oscillateurs, enveloppes courtes, micro-délais pour empiler les events d'un même coup. Un pickup de pièce joue une note différente d'une explosion de bombe, qui sonne différemment d'un hit ennemi.

Avantages concrets : aucun asset lourd à charger, audio désactivable sans rechargement, contenu safe en offline.

---

## Stack technique

<ul class="tech-stack">
<li><strong>React 19</strong> + <strong>TypeScript</strong></li>
<li><strong>Vite 8</strong> comme bundler — découpage vendor (react, Font Awesome) pour réduire le bundle initial</li>
<li><strong>Tailwind CSS v4</strong> en mode CSS-first (tokens OKLCH)</li>
<li><strong>Zustand 5</strong> — state management, persistance localStorage et IndexedDB</li>
<li><strong>Font Awesome Duotone</strong> — icônes des tuiles (ennemis, objets, nourriture)</li>
<li><strong>Web Audio API</strong> — audio procédural, aucun fichier sonore</li>
<li><strong>PWA</strong> via vite-plugin-pwa (Workbox, installable, offline)</li>
<li><strong>i18n</strong> maison (français et anglais)</li>
<li><strong>CSP</strong> — Content-Security-Policy avec hashes auto-générés</li>
<li><strong>Compression</strong> — Brotli + Gzip sur les assets</li>
</ul>

Architecture en couches : moteur de jeu pur dans `src/engine/` (TypeScript strict, zéro import React), store Zustand en coquille UI, composants sans logique métier.

---

## Évolutions

| Date       | Version | Description                                          |
| ---------- | ------- | ---------------------------------------------------- |
| Mars 2026  | 1.0     | Version initiale                                     |
| Avril 2026 | 2.0     | Moteur refactorisé, blessings, audio procédural, PWA |

---

## Bilan

**Durée** : ~3 semaines (mars-avril 2026).

**Ce que j'ai appris** :

- Construire un moteur de jeu en fonctions pures, découplé de React — la refonte a validé que séparer les deux couches dès le début aurait évité du travail en double
- Équilibrer un système de spawn probabiliste — les constantes ne sont jamais arbitraires, elles résultent de tests et d'ajustements successifs
- Synthétiser de l'audio procédural avec la Web Audio API sans librairie tierce

**Ce que je referais pareil** :

- Moteur pur dans `engine/`, zéro import React — c'est contraignant au début, payant ensuite

**Ce que je changerais** :

- Les bénédictions sont trop peu nombreuses : 4 options, c'est bien pour un MVP, mais une vingtaine rendrait les runs beaucoup plus variés.

</div>

<div data-lang="en">

## The pitch

Slide & Sorcery is a dungeon crawler on a 4×4 grid. One swipe moves everything at once: explosions, counter-attacks, combos stack up faster than you can track. You don't control every effect — you steer the chaos. When a run ends, the gold you collected unlocks a blessing for the next one.

**Play it**: [presque.cool/slide-sorcery/](https://presque.cool/slide-sorcery/)

<div class="quick-overview">
  <div class="overview-item">
    <span class="overview-label">Duration</span>
    <span class="overview-value">~3 weeks</span>
  </div>
  <div class="overview-item">
    <span class="overview-label">Period</span>
    <span class="overview-value">Mar 2026</span>
  </div>
  <div class="overview-item">
    <span class="overview-label">Stack</span>
    <div class="tech-tags">
      <span class="tech-tag">React</span>
      <span class="tech-tag">TypeScript</span>
      <span class="tech-tag">Zustand</span>
      <span class="tech-tag">PWA</span>
    </div>
  </div>
  <div class="overview-item">
    <span class="overview-label">Status</span>
    <span class="overview-value accent">✓ In production</span>
  </div>
</div>

---

## Why this project

2048 and Threes share a simple mechanic: slide tiles in one direction, merge what can merge. The idea here was to keep that gesture but push it somewhere else.

Enemies instead of numbers. Weapons, spells, bombs. In 2048, you always know what will happen before you swipe. Here, not really. You slide, things explode, a chain triggers, an enemy counter-attacks. The game stays readable, but it overflows a little — and that's what makes it interesting.

---

## The game engine

The first version handled collisions directly inside the Zustand store. It worked, but game logic and presentation state were tangled together. A refactor split the two layers cleanly.

`src/engine/` contains only pure TypeScript, no React imports. `gameLogic.ts` and `resolveCollision.ts` take a state and return the next one — pure functions. Zustand just stores the result and feeds the components.

This separation made adding mechanics far less risky. Each new tile type (checkpoint, weapons, shields) plugged into the engine without touching the rendering layer.

---

## The roguelite loop

Between runs, collected gold accumulates in a persistent vault. With 50 coins, the player unlocks a blessing before the next run.

4 blessings available:

- **Starter bomb**: an extra bomb from turn one.
- **Starter sword**: a weapon available immediately.
- **+1 max HP**: health cap raised to 4.
- **+10% combo**: score multiplier for combos.

No permanent progression: the blessing applies to the current run only. The game stays short and balanced without complex persistence.

---

## Combos and pity system

This is where the game holds together or breaks apart.

**Combos**: eliminating 2 or more threats in one move triggers a combo. Consecutive combos build a streak. At ×3: score bonus. At ×5: gold bonus. The reward is exponential, the loss immediate: one move without an elimination resets everything.

**Pity system**: food and coin spawns are random, but the engine tracks how many turns have passed since the last spawn of each type. After 3 turns without food, the probability of one appearing increases automatically. Same for coins. A similar anti-streak exists for skulls: too many consecutive enemies dampens the next wave.

The result: the game stays hard without becoming arbitrarily unfair. A player running low on resources sees them arrive a bit faster — no display, no announcement.

---

## Procedural audio

No audio files in the project. Every sound is synthesized in real time via the Web Audio API: oscillators, short envelopes, micro-delays to stack events from the same move. A coin pickup plays a different note than a bomb explosion, which sounds different from an enemy hit.

Practical benefits: no heavy assets to load, audio togglable without reloading, safe for offline.

---

## Tech stack

<ul class="tech-stack">
<li><strong>React 19</strong> + <strong>TypeScript</strong></li>
<li><strong>Vite 8</strong> as bundler — vendor splitting (react, Font Awesome) to reduce initial bundle</li>
<li><strong>Tailwind CSS v4</strong> in CSS-first mode (OKLCH tokens)</li>
<li><strong>Zustand 5</strong> — state management, localStorage and IndexedDB persistence</li>
<li><strong>Font Awesome Duotone</strong> — tile icons (enemies, items, food)</li>
<li><strong>Web Audio API</strong> — procedural audio, no sound files</li>
<li><strong>PWA</strong> via vite-plugin-pwa (Workbox, installable, offline)</li>
<li><strong>Custom i18n</strong> — French and English</li>
<li><strong>CSP</strong> — Content-Security-Policy with auto-generated hashes</li>
<li><strong>Compression</strong> — Brotli + Gzip on assets</li>
</ul>

Layered architecture: pure game engine in `src/engine/` (strict TypeScript, zero React imports), Zustand as the UI shell, components with no business logic.

---

## Timeline

| Date     | Version | Description                                         |
| -------- | ------- | --------------------------------------------------- |
| Mar 2026 | 1.0     | Initial version                                     |
| Apr 2026 | 2.0     | Refactored engine, blessings, procedural audio, PWA |

---

## Takeaways

**Duration**: ~3 weeks (March-April 2026).

**What I learned**:

- Building a game engine as pure functions, decoupled from React — the refactor confirmed that separating the two layers from day one would have saved duplicate work
- Balancing a probabilistic spawn system — the constants are never arbitrary, they come from testing and successive adjustments
- Synthesizing procedural audio with the Web Audio API without a third-party library

**What I'd do the same**:

- Pure engine in `engine/`, zero React imports — constrained at first, pays off later

**What I'd change**:

- The blessings pool is too small: 4 options is fine for an MVP, but a set of twenty would make runs feel much more varied.

</div>
