commit b3cfcd460c068b798523d4482247cec4d56b5fa8
Author: adrien <adrien@malingrey.fr>
Date:   Wed May 10 02:57:43 2023 +0200

    almost done

diff --git a/app.js b/app.js
new file mode 100644
index 0000000..0e4767e
--- /dev/null
+++ b/app.js
@@ -0,0 +1,1113 @@
+P = (x, y, z) => new THREE.Vector3(x, y, z)
+
+Array.prototype.pick = function() { return this.splice(Math.floor(Math.random()*this.length), 1)[0] }
+
+HTMLElement.prototype.addNewChild = function(tag, properties) {
+    let child = document.createElement(tag)
+    for (key in properties) {
+        child[key] = properties[key]
+    }
+    this.appendChild(child)
+}
+
+
+/* Contants */
+
+const ROWS = 24
+const SKYLINE = 20
+const COLUMNS = 10
+
+const DELAY = {
+    LOCK: 500,
+    FALL: 1000,
+}
+
+const FACING = {
+    NORTH: 0,
+    EAST:  1,
+    SOUTH: 2,
+    WEST:  3,
+}
+
+const TRANSLATION = {
+    NONE:  P( 0,  0, 0),
+    LEFT:  P(-1,  0, 0),
+    RIGHT: P( 1,  0, 0),
+    DOWN:  P( 0, -1, 0),
+}
+
+const ROTATION = {
+    CW:   1,  // ClockWise
+    CCW: -1,  // CounterClockWise
+}
+
+const T_SPIN = {
+    NONE:   "",
+    MINI:   "PETITE<br/>PIROUETTE",
+    T_SPIN: "PIROUETTE"
+}
+
+// score = AWARDED_LINE_CLEARS[tSpin][nbClearedLines]
+const AWARDED_LINE_CLEARS = {
+    [T_SPIN.NONE]:   [0, 1, 3, 5, 8],
+    [T_SPIN.MINI]:   [1, 2],
+    [T_SPIN.T_SPIN]: [4, 8, 12, 16]
+}
+
+const KEY_NAMES = {
+    ["ArrowLeft"]:  "←",
+    ["ArrowRight"]: "→",
+    ["ArrowUp"]:    "↑",
+    ["ArrowDown"]:  "↓",
+    [" "]:          "Espace",
+    ["Escape"]:     "Échap",
+    ["Enter"]:      "Entrée",
+    ["←"]:          "ArrowLeft",
+    ["→"]:          "ArrowRight",
+    ["↑"]:          "ArrowUp",
+    ["↓"]:          "ArrowDown",
+    ["Espace"]:     " ",
+    ["Échap"]:      "Escape",
+    ["Entrée"]:     "Enter",
+}
+
+const CLEARED_LINES_NAMES = [
+    "",  
+    "SOLO",
+    "DUO",
+    "TRIO",
+    "TETRA",
+]
+
+const GLOBAL_ROTATION = 0.0025
+
+const DARK_TEXTURE_ROTATION = 0.0015
+const DARK_MOVE_FORWARD = -0.0007
+const DARK_OPACITY = 0.4
+
+const COLORFULL_TEXTURE_ROTATION = 0.0015
+const COLORFULL_MOVE_FORWARD = -0.002
+const COLORFULL_OPACITY = 0.4
+
+
+/* Classes */
+
+class Scheduler {
+    constructor() {
+        this.intervalTasks = new Map()
+        this.timeoutTasks = new Map()
+    }
+
+    setInterval(func, delay, ...args) {
+        this.intervalTasks.set(func, window.setInterval(func, delay, ...args))
+    }
+
+    setTimeout(func, delay, ...args) {
+        this.timeoutTasks.set(func, window.setTimeout(func, delay, ...args))
+    }
+
+    clearInterval(func) {
+        if (this.intervalTasks.has(func))
+            window.clearInterval(this.intervalTasks.get(func))
+            this.intervalTasks.delete(func)
+    }
+
+    clearTimeout(func) {
+        if (this.timeoutTasks.has(func))
+            window.clearTimeout(this.timeoutTasks.get(func))
+            this.timeoutTasks.delete(func)
+    }
+}
+
+
+class Matrix extends THREE.Group {
+    constructor() {
+        super()
+        this.init()
+    }
+
+    init() {
+        this.cells = Array(ROWS).fill().map(() => Array(COLUMNS))
+    }
+
+    cellIsEmpty(position) {
+        return 0 <= position.x && position.x < COLUMNS &&
+               0 <= position.y && position.y < ROWS &&
+               !this.cells[position.y][position.x]
+    }
+
+    lock(piece) {
+        let minoes = Array.from(piece.children)
+        minoes.forEach(mino => {
+            mino.position.add(piece.position)
+            mino.material = piece.material
+            this.add(mino)
+            if (this.cellIsEmpty(mino.position)) {
+                this.cells[mino.position.y][mino.position.x] = mino
+            }
+        })
+        return minoes.some(mino => mino.position.y < SKYLINE)
+    }
+
+    clearLines() {
+        let nbClearedLines = 0
+        for (let y=ROWS-1; y>=0; y--) {
+            let row = this.cells[y]
+            if (row.filter(mino => mino).length == COLUMNS) {
+                nbClearedLines++
+                row.forEach(mino => this.remove(mino))
+                this.cells.splice(y, 1)
+                this.cells.push(Array(COLUMNS))
+            }
+        }
+        if (nbClearedLines) {
+            this.cells.forEach((rows, y) => {
+                rows.forEach((mino, x) => {
+                    mino.position.set(x, y, 0)
+                })
+            })
+        }
+        return nbClearedLines
+    }
+}
+
+
+class Mino extends THREE.Mesh {
+    constructor() {
+        super(Mino.prototype.geometry)
+        scene.add(this)
+    }
+}
+const minoFaceShape = new THREE.Shape()
+minoFaceShape.moveTo(.1, .1)
+minoFaceShape.lineTo(.1, .9)
+minoFaceShape.lineTo(.9, .9)
+minoFaceShape.lineTo(.9, .1)
+minoFaceShape.lineTo(.1, .1)
+const minoExtrudeSettings = {
+	steps: 1,
+	depth: .8,
+	bevelEnabled: true,
+	bevelThickness: .1,
+	bevelSize: .1,
+	bevelOffset: 0,
+	bevelSegments: 1
+}
+Mino.prototype.geometry = new THREE.ExtrudeGeometry(minoFaceShape, minoExtrudeSettings)
+
+
+class Tetromino extends THREE.Group {
+    static randomBag = []
+    static get random() {
+        if (!this.randomBag.length) this.randomBag = [I, J, L, O, S, T, Z]
+        return this.randomBag.pick()
+    }
+
+    constructor() {
+        super()
+        this.rotatedLast = false
+        this.rotationPoint4Used = false
+        this.holdEnabled = true
+        let material = this.material
+        for (let i=0; i<4; i++) {
+            this.add(new Mino())
+        }
+        this.facing = 0 
+        this.locked = false
+    }
+
+    set facing(facing) {
+        this._facing = facing
+        this.minoesPosition[this.facing].forEach(
+            (position, i) => this.children[i].position.set(position.x, position.y, position.z)
+        )
+    }
+
+    get facing() {
+        return this._facing
+    }
+
+    set locked(locked) {
+        this._locked = locked
+        if (locked) {
+            this.children.forEach(mino => mino.material = this.lockedMaterial)
+        } else {
+            this.children.forEach(mino => mino.material = this.material)
+        }
+    }
+
+    get locked() {
+        return this._locked
+    }
+
+    canMove(translation, facing=this.facing) {
+        let testPosition = this.position.clone().add(translation)
+        return this.minoesPosition[facing].every(minoPosition => matrix.cellIsEmpty(minoPosition.clone().add(testPosition)))
+    }
+    
+    move(translation, testFacing) {
+        if (this.canMove(translation, testFacing)) {
+            scheduler.clearTimeout(lockDown)
+            this.position.add(translation)
+            if (!testFacing) {
+                this.rotatedLast = false
+                ghost.copy(this)
+            }
+            if (this.canMove(TRANSLATION.DOWN)) {
+                this.locked = false
+                scene.add(ghost)
+            } else {
+                this.locked = true
+                scene.remove(ghost)
+                scheduler.setTimeout(lockDown, stats.lockDelay)
+            }
+            return true
+        } else if (translation == TRANSLATION.DOWN) {
+            this.locked = true
+            if (!scheduler.timeoutTasks.has(lockDown))
+                scheduler.setTimeout(lockDown, stats.lockDelay)
+        }
+    }
+    
+    rotate(rotation) {
+        let testFacing = (this.facing + rotation + 4) % 4
+        return this.srs[this.facing][rotation].some((translation, rotationPoint) => {
+            if (this.move(translation, testFacing)) {
+                //rotateSound.play()
+                this.facing = testFacing
+                this.rotatedLast = true
+                if (rotationPoint == 4) this.rotationPoint4Used = true
+                //favicon.href = this.favicon_href
+                ghost.copy(this)
+                return true
+            }
+        })
+    }
+
+    get tSpin() {
+        return T_SPIN.NONE
+    }
+}
+// Super Rotation System
+// freedom of movement = srs[piece.facing][rotation]
+Tetromino.prototype.srs = [
+    { [ROTATION.CW]: [P(0, 0, 0), P(-1, 0, 0), P(-1,  1, 0), P(0, -2, 0), P(-1, -2, 0)], [ROTATION.CCW]: [P(0, 0, 0), P( 1, 0, 0), P( 1,  1, 0), P(0, -2, 0), P( 1, -2, 0)] },
+    { [ROTATION.CW]: [P(0, 0, 0), P( 1, 0, 0), P( 1, -1, 0), P(0,  2, 0), P( 1,  2, 0)], [ROTATION.CCW]: [P(0, 0, 0), P( 1, 0, 0), P( 1, -1, 0), P(0,  2, 0), P( 1,  2, 0)] },
+    { [ROTATION.CW]: [P(0, 0, 0), P( 1, 0, 0), P( 1,  1, 0), P(0, -2, 0), P( 1, -2, 0)], [ROTATION.CCW]: [P(0, 0, 0), P(-1, 0, 0), P(-1,  1, 0), P(0, -2, 0), P(-1, -2, 0)] },
+    { [ROTATION.CW]: [P(0, 0, 0), P(-1, 0, 0), P(-1, -1, 0), P(0,  2, 0), P(-1,  2, 0)], [ROTATION.CCW]: [P(0, 0, 0), P(-1, 0, 0), P(-1, -1, 0), P(0,  2, 0), P(-1,  2, 0)] },
+]
+minoRenderTarget = new THREE.WebGLCubeRenderTarget(256)
+minoRenderTarget.texture.type = THREE.HalfFloatType
+minoCamera = new THREE.CubeCamera(1, 1000, minoRenderTarget)
+minoCamera.position.set(5, 10, 0)
+Tetromino.prototype.lockedMaterial = new THREE.MeshBasicMaterial({
+    color: 0xffffff,
+    reflectivity: .6,
+    envMap: minoRenderTarget.texture
+})
+
+class I extends Tetromino {}
+I.prototype.minoesPosition = [
+    [P(-1,  0, 0), P(0,  0, 0), P(1,  0, 0), P(2,  0, 0)],
+    [P( 1,  1, 0), P(1,  0, 0), P(1, -1, 0), P(1, -2, 0)],
+    [P(-1, -1, 0), P(0, -1, 0), P(1, -1, 0), P(2, -1, 0)],
+    [P( 0,  1, 0), P(0,  0, 0), P(0, -1, 0), P(0, -2, 0)],
+]
+I.prototype.srs = [
+    { [ROTATION.CW]: [P(0, 0, 0), P(-2, 0, 0), P( 1, 0, 0), P(-2, -1, 0), P( 1,  2, 0)], [ROTATION.CCW]: [P(0, 0, 0), P(-1, 0, 0), P( 2, 0, 0), P(-1,  2, 0), P( 2, -1, 0)] },
+    { [ROTATION.CW]: [P(0, 0, 0), P(-1, 0, 0), P( 2, 0, 0), P(-1,  2, 0), P( 2, -1, 0)], [ROTATION.CCW]: [P(0, 0, 0), P( 2, 0, 0), P(-1, 0, 0), P( 2,  1, 0), P(-1, -2, 0)] },
+    { [ROTATION.CW]: [P(0, 0, 0), P( 2, 0, 0), P(-1, 0, 0), P( 2,  1, 0), P(-1, -2, 0)], [ROTATION.CCW]: [P(0, 0, 0), P( 1, 0, 0), P(-2, 0, 0), P( 1, -2, 0), P(-2,  1, 0)] },
+    { [ROTATION.CW]: [P(0, 0, 0), P( 1, 0, 0), P(-2, 0, 0), P( 1, -2, 0), P(-2,  1, 0)], [ROTATION.CCW]: [P(0, 0, 0), P(-2, 0, 0), P( 1, 0, 0), P(-2, -1, 0), P( 1,  2, 0)] },
+]
+I.prototype.material = new THREE.MeshBasicMaterial({
+    color: 0xafeff9,
+    reflectivity: .6,
+    envMap: minoRenderTarget.texture
+})
+I.prototype.ghostMaterial = new THREE.MeshBasicMaterial({
+    color: 0xafeff9,
+    reflectivity: .6,
+    envMap: minoRenderTarget.texture,
+    transparent: true,
+    opacity: 0.5
+})
+
+class J extends Tetromino {}
+J.prototype.minoesPosition = [
+    [P(-1,  1, 0), P(-1,  0, 0), P(0, 0, 0), P(1,  0, 0)],
+    [P( 0,  1, 0), P( 1,  1, 0), P(0, 0, 0), P(0, -1, 0)],
+    [P( 1, -1, 0), P(-1,  0, 0), P(0, 0, 0), P(1,  0, 0)],
+    [P( 0,  1, 0), P(-1, -1, 0), P(0, 0, 0), P(0, -1, 0)],
+]
+J.prototype.material = new THREE.MeshBasicMaterial({
+    color: 0xb8b4ff,
+    reflectivity: .6,
+    envMap: minoRenderTarget.texture
+})
+J.prototype.ghostMaterial = new THREE.MeshBasicMaterial({
+    color: 0xb8b4ff,
+    reflectivity: .6,
+    envMap: minoRenderTarget.texture,
+    transparent: true,
+    opacity: 0.5
+})
+
+class L extends Tetromino {}
+L.prototype.minoesPosition = [
+    [P(-1, 0, 0), P(0, 0, 0), P(1,  0, 0), P( 1,  1, 0)],
+    [P(0,  1, 0), P(0, 0, 0), P(0, -1, 0), P( 1, -1, 0)],
+    [P(-1, 0, 0), P(0, 0, 0), P(1,  0, 0), P(-1, -1, 0)],
+    [P(0,  1, 0), P(0, 0, 0), P(0, -1, 0), P(-1,  1, 0)],
+]
+L.prototype.material = new THREE.MeshBasicMaterial({
+    color: 0xfdd0b7,
+    reflectivity: .6,
+    envMap: minoRenderTarget.texture
+})
+L.prototype.ghostMaterial = new THREE.MeshBasicMaterial({
+    color: 0xfdd0b7,
+    reflectivity: .6,
+    envMap: minoRenderTarget.texture,
+    transparent: true,
+    opacity: 0.5
+})
+
+class O extends Tetromino {}
+O.prototype.minoesPosition = [
+    [P(0, 0, 0), P(1, 0, 0), P(0, 1, 0), P(1, 1, 0)]
+]
+O.prototype.srs = [
+    {[ROTATION.CW]: [], [ROTATION.CCW]: []}
+]
+O.prototype.material = new THREE.MeshBasicMaterial({
+    color: 0xffedac,
+    reflectivity: .6,
+    envMap: minoRenderTarget.texture
+})
+O.prototype.ghostMaterial = new THREE.MeshBasicMaterial({
+    color: 0xffedac,
+    reflectivity: .6,
+    envMap: minoRenderTarget.texture,
+    transparent: true,
+    opacity: 0.5
+})
+
+class S extends Tetromino {}
+S.prototype.minoesPosition = [
+    [P(-1,  0, 0), P(0, 0, 0), P( 0, 1, 0), P(1,  1, 0)],
+    [P( 0,  1, 0), P(0, 0, 0), P( 1, 0, 0), P(1, -1, 0)],
+    [P(-1, -1, 0), P(0, 0, 0), P( 1, 0, 0), P(0, -1, 0)],
+    [P(-1,  1, 0), P(0, 0, 0), P(-1, 0, 0), P(0, -1, 0)],
+]
+S.prototype.material = new THREE.MeshBasicMaterial({
+    color: 0xC8FBA8,
+    reflectivity: .6,
+    envMap: minoRenderTarget.texture
+})
+S.prototype.ghostMaterial = new THREE.MeshBasicMaterial({
+    color: 0xC8FBA8,
+    reflectivity: .6,
+    envMap: minoRenderTarget.texture,
+    transparent: true,
+    opacity: 0.5
+})
+
+class T extends Tetromino {
+    get tSpin() {
+        if (this.rotatedLast) {
+            let [a, b, c, d] = this.tSlots[piece.facing]
+                .map(position => !matrix.cellIsEmpty(position.clone().add(this.position)))
+            if (a && b && (c || d))
+                return T_SPIN.T_SPIN
+            else if (c && d && (a || b))
+                return this.rotationPoint4Used ? T_SPIN.T_SPIN : T_SPIN.MINI
+        }
+        return T_SPIN.NONE
+    }
+}
+T.prototype.minoesPosition = [
+    [P(-1, 0, 0), P(0, 0, 0), P(1,  0, 0), P( 0,  1, 0)],
+    [P( 0, 1, 0), P(0, 0, 0), P(1,  0, 0), P( 0, -1, 0)],
+    [P(-1, 0, 0), P(0, 0, 0), P(1,  0, 0), P( 0, -1, 0)],
+    [P( 0, 1, 0), P(0, 0, 0), P(0, -1, 0), P(-1,  0, 0)],
+]
+T.prototype.tSlots = [
+    [P(-1,  1, 0), P( 1,  1, 0), P( 1, -1, 0), P(-1, -1, 0)],
+    [P( 1,  1, 0), P( 1, -1, 0), P(-1, -1, 0), P(-1,  1, 0)],
+    [P( 1, -1, 0), P(-1, -1, 0), P(-1,  1, 0), P( 1,  1, 0)],
+    [P(-1, -1, 0), P(-1,  1, 0), P( 1,  1, 0), P( 1, -1, 0)],
+]
+T.prototype.material = new THREE.MeshBasicMaterial({
+    color: 0xedb2ff,
+    reflectivity: .6,
+    envMap: minoRenderTarget.texture
+})
+T.prototype.ghostMaterial = new THREE.MeshBasicMaterial({
+    color: 0xedb2ff,
+    reflectivity: .6,
+    envMap: minoRenderTarget.texture,
+    transparent: true,
+    opacity: 0.5
+})
+
+class Z extends Tetromino {}
+Z.prototype.minoesPosition = [
+    [P(-1,  1, 0), P( 0, 1, 0), P(0,  0, 0), P( 1,  0, 0)],
+    [P( 1,  1, 0), P( 1, 0, 0), P(0,  0, 0), P( 0, -1, 0)],
+    [P(-1,  0, 0), P( 0, 0, 0), P(0, -1, 0), P( 1, -1, 0)],
+    [P( 0,  1, 0), P(-1, 0, 0), P(0,  0, 0), P(-1, -1, 0)]
+]
+Z.prototype.material = new THREE.MeshBasicMaterial({
+    color: 0xffb8c5,
+    reflectivity: .6,
+    envMap: minoRenderTarget.texture
+})
+Z.prototype.ghostMaterial = new THREE.MeshBasicMaterial({
+    color: 0xffb8c5,
+    reflectivity: .6,
+    envMap: minoRenderTarget.texture,
+    transparent: true,
+    opacity: 0.5
+})
+
+class Ghost extends Tetromino {
+    copy(piece) {
+        this.position.set(piece.position.x, piece.position.y, piece.position.z)
+        this.facing = piece.facing
+        this.minoesPosition = piece.minoesPosition
+        piece.children.forEach((mino, i) => {
+            this.children[i].position.set(mino.position.x, mino.position.y, mino.position.z)
+            this.children[i].material = piece.ghostMaterial
+        })
+        while (this.canMove(TRANSLATION.DOWN)) this.position.y--
+    }
+}
+Ghost.prototype.minoesPosition = [
+    [P(0, 0, 0, 0), P(0, 0, 0, 0), P(0, 0, 0, 0), P(0, 0, 0, 0)],
+]
+
+
+class Settings {
+    constructor() {
+        this.form = settingsForm
+        this.load()
+        this.modal = new bootstrap.Modal('#settingsModal')
+        settingsModal.addEventListener('shown.bs.modal', () => {
+            resumeButton.focus()
+        })
+        this.init()
+    }
+
+    load() {
+        for (let element of settingsForm.elements) {
+            if (element.name) {
+                if (localStorage[element.name]) element.value = localStorage[element.name]
+            }
+        }
+    }
+
+    save() {
+        for (let element of settingsForm.elements) {
+            if (element.name) {
+                localStorage[element.name] = element.value
+            }
+        }
+    }
+
+    init() {
+        this.form.onsubmit = newGame
+        levelInput.name = "startLevel"
+        levelInput.disabled = false
+        titleHeader.innerHTML = "te<strong>T</strong>ra"
+        resumeButton.innerHTML = "Jouer"
+    }
+
+    show() {
+        resumeButton.disabled = false
+        settings.form.classList.remove('was-validated')
+        settings.modal.show()
+        settings.form.reportValidity()
+    }
+
+    getInputs() {
+        for (let input of this.form.querySelectorAll("input[type='text']")) {
+            this[input.name] = KEY_NAMES[input.value] || input.value
+        }
+        for (let input of this.form.querySelectorAll("input[type='number'], input[type='range']")) {
+            this[input.name] = input.valueAsNumber
+        }
+        for (let input of this.form.querySelectorAll("input[type='checkbox']")) {
+            this[input.name] = input.checked == true
+        }
+    
+        this.keyBind = {}
+        for (let actionName in playerActions) {
+            this.keyBind[settings[actionName]] = playerActions[actionName]
+        }
+    }
+}
+
+function changeKey(input) {
+    prevValue = input.value
+    input.value = ""
+    input.onkeydown = function (event) {
+        event.preventDefault()
+        input.value = KEY_NAMES[event.key] || event.key
+        input.blur()
+    }
+    input.onblur = function (event) {
+        if (input.value == "") input.value = prevValue
+        input.onkeydown = null
+        input.onblur = null
+    }
+}
+
+
+class Stats {
+    constructor() {
+        this.modal = new bootstrap.Modal('#statsModal')
+        this.load()
+        this.init()
+    }
+
+    load() {
+        this.highScore = Number(localStorage["highScore"]) || 0
+    }
+
+    init() {
+        this.score = 0
+        this.goal = 0
+        this.combo = 0
+        this.b2b = 0
+        this.startTime = new Date()
+        this.lockDelay = DELAY.LOCK
+        this.totalClearedLines = 0
+        this.nbQuatris = 0
+        this.nbTSpin = 0
+        this.maxCombo = 0
+        this.maxB2B = 0
+    }
+
+    set score(score) {
+        this._score = score
+        if (score > this.highScore) {
+            this.highScore = score
+        }
+    }
+
+    get score() {
+        return this._score
+    }
+
+    set level(level) {
+        this._level = level
+        this.goal += level * 5
+        if (level <= 20){
+            this.fallPeriod = 1000 * Math.pow(0.8 - ((level - 1) * 0.007), level - 1)
+        }
+        if (level > 15)
+            this.lockDelay = 500 * Math.pow(0.9, level - 15)
+        levelInput.value = level
+        messagesSpan.addNewChild("div", { className: "show-level-animation", innerHTML: `<h1>NIVEAU<br/>${this.level}</h1>` })
+    }
+
+    get level() {
+        return this._level
+    }
+
+    set time(time) {
+        this.startTime = new Date() - time
+    }
+
+    get time() {
+        return new Date() - this.startTime
+    }
+
+    lockDown(nbClearedLines, tSpin) {
+        this.totalClearedLines += nbClearedLines
+        if (nbClearedLines == 4) this.nbQuatris++
+        if (tSpin == T_SPIN.T_SPIN) this.nbTSpin++
+
+        // Cleared lines & T-Spin
+        let awardedLineClears = AWARDED_LINE_CLEARS[tSpin][nbClearedLines]
+        let patternScore = 100 * this.level * awardedLineClears
+        if (tSpin) messagesSpan.addNewChild("div", {
+            className: "rotate-in-animation",
+            innerHTML: tSpin
+        })
+        if (nbClearedLines) messagesSpan.addNewChild("div", {
+            className: "zoom-in-animation",
+            innerHTML: CLEARED_LINES_NAMES[nbClearedLines]
+        })
+        if (patternScore) {
+            messagesSpan.addNewChild("div", {
+                className: "zoom-in-animation",
+                style: "animation-delay: .2s",
+                innerHTML: patternScore
+            })
+            this.score += patternScore
+        }
+
+        // Combo
+        if (nbClearedLines) {
+            this.combo++
+            if (this.combo > this.maxCombo) this.maxCombo = this.combo
+            if (this.combo >= 1) {
+                let comboScore = (nbClearedLines == 1 ? 20 : 50) * this.combo * this.level
+                if (this.combo == 1) {
+                    messagesSpan.addNewChild("div", {
+                        className: "zoom-in-animation",
+                        style: "animation-delay: .4s",
+                        innerHTML: `COMBO<br/>${comboScore}`
+                    })
+                } else {
+                    messagesSpan.addNewChild("div", {
+                        className: "zoom-in-animation",
+                        style: "animation-delay: .4s",
+                        innerHTML: `COMBO x${this.combo}<br/>${comboScore}`
+                    })
+                }
+                this.score += comboScore
+            }
+        } else {
+            this.combo = -1
+        }
+
+        // Back to back sequence
+        if ((nbClearedLines == 4) || (tSpin && nbClearedLines)) {
+            this.b2b++
+            if (this.b2b > this.maxB2B) this.maxB2B = this.b2b
+            if (this.b2b >= 1) {
+                let b2bScore = patternScore / 2
+                if (this.b2b == 1) {
+                    messagesSpan.addNewChild("div", {
+                        className: "zoom-in-animation",
+                        style: "animation-delay: .4s",
+                        innerHTML: `BOUT À BOUT<br/>${b2bScore}`
+                    })
+                } else {
+                    messagesSpan.addNewChild("div", {
+                        className: "zoom-in-animation",
+                        style: "animation-delay: .4s",
+                        innerHTML: `BOUT À BOUT x${this.b2b}<br/>${b2bScore}`
+                    })
+                }
+                this.score += b2bScore
+            }
+        } else if (nbClearedLines && !tSpin ) {
+            if (this.b2b >= 1) {
+                messagesSpan.addNewChild("div", {
+                    className: "zoom-in-animation",
+                    style: "animation-delay: .4s",
+                    innerHTML: `FIN DU BOUT À BOUT`
+                })
+            }
+            this.b2b = -1
+        }
+
+        this.goal -= awardedLineClears
+        if (this.goal <= 0) this.level++
+    }
+
+    show() {
+        let time = stats.time
+        statsModalScoreCell.innerText           = this.score.toLocaleString()
+        statsModalHighScoreCell.innerText       = this.highScore.toLocaleString()
+        statsModalLevelCell.innerText           = this.level
+        statsModalTimeCell.innerText            = this.timeFormat.format(time)
+        statsModaltotalClearedLines.innerText   = this.totalClearedLines
+        statsModaltotalClearedLinesPM.innerText = (stats.totalClearedLines * 60000 / time).toFixed(2)
+        statsModalNbQuatris.innerText           = this.nbQuatris
+        statsModalNbTSpin.innerText             = this.nbTSpin
+        statsModalMaxCombo.innerText            = this.maxCombo
+        statsModalMaxB2B.innerText              = this.maxB2B
+        this.modal.show()
+    }
+
+    save() {
+        localStorage["highScore"] = this.highScore
+    }
+}
+Stats.prototype.timeFormat = new Intl.DateTimeFormat("fr-FR", {
+    hour: "numeric",
+    minute: "2-digit",
+    second: "2-digit",
+    timeZone: "UTC"
+})
+
+
+/* Scene */
+
+const scene = new THREE.Scene()
+
+const renderer = new THREE.WebGLRenderer()
+renderer.setSize(window.innerWidth, window.innerHeight)
+document.body.appendChild(renderer.domElement)
+
+const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 400)
+camera.position.set(5, 6.5, 16)
+camera.lookAt(5, 8.5, 0)
+
+const commonCylinderGeometry = new THREE.CylinderGeometry(25, 25, 400, 20, 1, true)
+
+// dark space full of stars - background cylinder
+const darkCylinderTexture = new THREE.TextureLoader().load("images/dark.jpg")
+darkCylinderTexture.wrapS = THREE.RepeatWrapping
+darkCylinderTexture.wrapT = THREE.MirroredRepeatWrapping
+darkCylinderTexture.repeat.set(1, 1)
+const darkCylinder = new THREE.Mesh(
+  commonCylinderGeometry,
+  new THREE.MeshLambertMaterial({
+    side: THREE.BackSide,
+    map: darkCylinderTexture,
+    blending: THREE.AdditiveBlending,
+    opacity: DARK_OPACITY
+  })
+)
+darkCylinder.position.set(5, 10, -10)
+scene.add(darkCylinder)
+
+// colourfull space full of nebulas - main universe cylinder
+const colorFullCylinderTexture = new THREE.TextureLoader().load("images/colorfull.jpg")
+colorFullCylinderTexture.wrapS = THREE.RepeatWrapping
+colorFullCylinderTexture.wrapT = THREE.MirroredRepeatWrapping
+colorFullCylinderTexture.repeat.set(1, 1)
+const colorFullCylinder = new THREE.Mesh(
+  commonCylinderGeometry,
+  new THREE.MeshBasicMaterial({
+    side: THREE.BackSide,
+    map: colorFullCylinderTexture,
+    blending: THREE.AdditiveBlending,
+    opacity: COLORFULL_OPACITY
+  })
+)
+colorFullCylinder.position.set(5, 10, -10)
+scene.add(colorFullCylinder)
+
+const light = new THREE.AmbientLight(0xffffff, 10)
+scene.add(light)
+
+const edgeMaterial = new THREE.MeshBasicMaterial({
+    color: 0x88abe0,
+    transparent: true,
+    opacity: 0.5,
+    reflectivity: .4,
+    envMap: minoRenderTarget.texture
+})
+
+const edgeShape = new THREE.Shape()
+edgeShape.moveTo(-.3, SKYLINE)
+edgeShape.lineTo( 0, SKYLINE)
+edgeShape.lineTo( 0,  0)
+edgeShape.lineTo(COLUMNS,  0)
+edgeShape.lineTo(COLUMNS, SKYLINE)
+edgeShape.lineTo(COLUMNS+.3, SKYLINE)
+edgeShape.lineTo(COLUMNS+.3, -.3)
+edgeShape.lineTo(-.3, -.3)
+edgeShape.moveTo(-.3, SKYLINE)
+const edgeExtrudeSettings = {
+	depth: 1,
+	bevelEnabled: false,
+}
+const edge = new THREE.Mesh(
+    //new THREE.PlaneGeometry(10, SKYLINE),
+    new THREE.ExtrudeGeometry(edgeShape, edgeExtrudeSettings),
+    edgeMaterial
+)
+//edge.position.set(5, 10, 0)
+scene.add(edge)
+
+const holdQueue = new THREE.Group()
+holdQueue.position.set(-5, 16, 0)
+scene.add(holdQueue)
+const matrix = new Matrix()
+scene.add(matrix)
+const nextQueue = new THREE.Group()
+nextQueue.position.set(13, 16, 0)
+scene.add(nextQueue)
+let ghost = new Ghost()
+
+const lineClearSound = new Audio("audio/line_clear.ogg")
+const tetrisSound    = new Audio("audio/tetris.ogg")
+const music          = new Audio("https://iterations.org/files/music/remixes/Tetris_CheDDer_OC_ReMix.mp3")
+music.loop = true
+
+window.addEventListener("resize", () => {
+  renderer.setSize(window.innerWidth, window.innerHeight)
+  camera.aspect = window.innerWidth / window.innerHeight
+  camera.updateProjectionMatrix()
+})
+
+function animate() {
+    requestAnimationFrame(animate)
+
+    darkCylinder.rotation.y += GLOBAL_ROTATION
+    darkCylinderTexture.offset.y -= DARK_MOVE_FORWARD
+    darkCylinderTexture.offset.x -= DARK_TEXTURE_ROTATION
+
+    colorFullCylinder.rotation.y += GLOBAL_ROTATION
+    colorFullCylinderTexture.offset.y -= COLORFULL_MOVE_FORWARD
+    colorFullCylinderTexture.offset.x -= COLORFULL_TEXTURE_ROTATION
+
+    renderer.render(scene, camera)
+    minoCamera.update(renderer, scene)
+
+}
+animate()
+
+
+/* Game logic */
+
+messagesSpan.onanimationend = function(event) {
+    event.target.remove() 
+}
+
+let scheduler = new Scheduler()
+let settings = new Settings()
+let stats = new Stats()
+let playing = false
+//let favicon = document.querySelector("link[rel~='icon']")
+
+function pauseSettings() {
+    scheduler.clearInterval(fall)
+    scheduler.clearTimeout(lockDown)
+    scheduler.clearTimeout(repeat)
+    scheduler.clearInterval(autorepeat)
+    stats.pauseTime = stats.time
+    music.pause()
+
+    document.onkeydown = null
+
+    settings.show()
+}
+onblur = pauseSettings
+
+pauseSettings()
+
+function newGame() {
+    if (!settings.form.checkValidity()) {
+        event.preventDefault()
+        event.stopPropagation()
+        settings.form.reportValidity()
+        settings.form.classList.add('was-validated')
+    } else {
+        levelInput.name = "level"
+        levelInput.disabled = true
+        titleHeader.innerHTML = "PAUSE"
+        resumeButton.innerHTML = "Reprendre"
+        event.target.onsubmit = resume
+        holdQueue.piece = null
+        nextQueue.piece = new Tetromino.random()
+        nextQueue.add(nextQueue.piece)
+        stats.level = levelInput.valueAsNumber
+        localStorage["startLevel"] = levelInput.value
+        playing = true
+        resume(event)
+    }
+}
+
+function resume(event) {
+    event.preventDefault()
+    event.stopPropagation()
+
+    settings.form.reportValidity()
+    settings.form.classList.add('was-validated')
+
+    if (settings.form.checkValidity()) {
+        settings.modal.hide()
+        settings.getInputs()
+
+        document.onkeydown = onkeydown
+        document.onkeyup = onkeyup
+    
+        stats.time = stats.pauseTime
+
+        lineClearSound.volume = settings.sfxVolume
+        tetrisSound.volume    = settings.sfxVolume
+        if (music.volume > 0) {
+            music.volume      = settings.musicVolume
+            music.play()
+        }
+
+        if (piece) scheduler.setInterval(fall, stats.fallPeriod)
+        else generate()
+    }
+}
+
+var piece = null
+function generate(heldPiece) {
+    if (heldPiece) {
+        piece = heldPiece
+    } else {
+        piece = nextQueue.piece
+        nextQueue.piece = new Tetromino.random()
+        nextQueue.add(nextQueue.piece)
+    }
+    piece.position.set(4, SKYLINE, 0)
+    scene.add(piece)
+    ghost.copy(piece)
+    scene.add(ghost)
+
+    if (piece.canMove(TRANSLATION.NONE)) {
+        scheduler.setInterval(fall, stats.fallPeriod)
+    } else {
+        gameOver() // block out
+    }
+}
+
+let playerActions = {
+    moveLeft: () => piece.move(TRANSLATION.LEFT),
+
+    moveRight: () => piece.move(TRANSLATION.RIGHT),
+
+    rotateClockwise: () => piece.rotate(ROTATION.CW),
+
+    rotateCounterclockwise: () => piece.rotate(ROTATION.CCW),
+
+    softDrop: function() {
+        if (piece.move(TRANSLATION.DOWN)) stats.score++
+    },
+
+    hardDrop: function() {
+        scheduler.clearTimeout(lockDown)
+        //hardDropSound.play()
+        while (piece.move(TRANSLATION.DOWN)) stats.score +=2
+        lockDown()
+    },
+
+    hold: function() {
+        if (piece.holdEnabled) {
+            scheduler.clearInterval(fall)
+            scheduler.clearTimeout(lockDown)
+    
+            heldpiece = holdQueue.piece
+            holdQueue.piece = piece
+            holdQueue.piece.holdEnabled = false
+            holdQueue.piece.locked = false
+            holdQueue.piece.position.set(0, 0, 0)
+            holdQueue.piece.facing = FACING.NORTH
+            holdQueue.add(holdQueue.piece)
+            generate(heldpiece)
+        }
+    },
+    
+    pause: pauseSettings,
+}
+
+// Handle player inputs
+const REPEATABLE_ACTIONS = [
+    playerActions.moveLeft,
+    playerActions.moveRight,
+    playerActions.softDrop
+]
+pressedKeys = new Set()
+actionsQueue = []
+
+function onkeydown(event) {
+    if (event.key in settings.keyBind) {
+        event.preventDefault()
+        if (!pressedKeys.has(event.key)) {
+            pressedKeys.add(event.key)
+            action = settings.keyBind[event.key]
+            action()
+            if (REPEATABLE_ACTIONS.includes(action)) {
+                actionsQueue.unshift(action)
+                scheduler.clearTimeout(repeat)
+                scheduler.clearInterval(autorepeat)
+                if (action == playerActions.softDrop) scheduler.setInterval(autorepeat, settings.fallPeriod/20)
+                else scheduler.setTimeout(repeat, settings.das)
+            }
+        }
+    }
+}
+
+function repeat() {
+    if (actionsQueue.length) {
+        actionsQueue[0]()
+        scheduler.setInterval(autorepeat, settings.arr)
+    }
+}
+
+function autorepeat() {
+    if (actionsQueue.length) {
+        actionsQueue[0]()
+    } else {
+        scheduler.clearInterval(autorepeat)
+    }
+}
+
+function onkeyup(event) {
+    if (event.key in settings.keyBind) {
+        event.preventDefault()
+        pressedKeys.delete(event.key)
+        action = settings.keyBind[event.key]
+        if (actionsQueue.includes(action)) {
+            actionsQueue.splice(actionsQueue.indexOf(action), 1)
+            if (!actionsQueue.length) {
+                scheduler.clearTimeout(repeat)
+                scheduler.clearInterval(autorepeat)
+            }
+        }
+    }
+}
+
+function fall() {
+    piece.move(TRANSLATION.DOWN)
+}
+
+function lockDown() {
+    scheduler.clearTimeout(lockDown)
+    scheduler.clearInterval(fall)
+
+    if (matrix.lock(piece)) {
+        scene.remove(piece)
+        let tSpin = piece.tSpin
+        let nbClearedLines = matrix.clearLines()
+        if (nbClearedLines == 4 || (nbClearedLines && tSpin)) {
+            tetrisSound.currentTime = 0
+            tetrisSound.play()
+        } else if (nbClearedLines || tSpin) {
+            lineClearSound.currentTime = 0
+            lineClearSound.play()
+        }
+        stats.lockDown(nbClearedLines, tSpin)
+
+        generate()
+    } else {
+        gameOver() // lock out
+    }
+}
+
+function gameOver() {
+    piece.locked = false
+
+    document.onkeydown = null
+    onblur = null
+    playing = false
+    music.pause()
+
+    stats.show()
+}
+
+function restart() {
+    stats.modal.hide()
+    stats.init()
+    settings.init()
+    holdQueue.remove(holdQueue.piece)
+    Array.from(matrix.children).forEach(mino => matrix.remove(mino))
+    matrix.init()
+    nextQueue.remove(nextQueue.piece)
+    music.currentTime = 0
+    pauseSettings()
+}
+
+window.onbeforeunload = function(event) {
+    stats.save()
+    settings.save()
+    if (playing) return false
+}
+
+
+if ('serviceWorker' in navigator) {
+    navigator.serviceWorker.register('service-worker.js')
+}
\ No newline at end of file
diff --git a/audio/line_clear.ogg b/audio/line_clear.ogg
new file mode 100644
index 0000000..bc6616d
Binary files /dev/null and b/audio/line_clear.ogg differ
diff --git a/audio/tetris.ogg b/audio/tetris.ogg
new file mode 100644
index 0000000..8af92f0
Binary files /dev/null and b/audio/tetris.ogg differ
diff --git a/audio/tetris.xt b/audio/tetris.xt
new file mode 100644
index 0000000..22b1b37
Binary files /dev/null and b/audio/tetris.xt differ
diff --git a/favicon.ico b/favicon.ico
new file mode 100644
index 0000000..ff44603
Binary files /dev/null and b/favicon.ico differ
diff --git a/images/colorfull.jpg b/images/colorfull.jpg
new file mode 100644
index 0000000..b917cf0
Binary files /dev/null and b/images/colorfull.jpg differ
diff --git a/images/dark.jpg b/images/dark.jpg
new file mode 100644
index 0000000..b6866d3
Binary files /dev/null and b/images/dark.jpg differ
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..0fe4814
--- /dev/null
+++ b/index.html
@@ -0,0 +1,150 @@
+<!DOCTYPE html>
+
+<html lang="fr">
+
+    <head>
+      <meta charset="utf-8" />
+        <title>teTra</title>
+        <link rel="icon" href="favicon.ico">
+        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" integrity="sha384-KK94CHFLLe+nY2dmCWGMq91rCGa5gtU4mk92HdvYe+M/SXH301p5ILy+dN9+nJOZ" crossorigin="anonymous">
+        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.4/font/bootstrap-icons.css">
+        <link rel="stylesheet" href="style.css">
+        <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r122/three.min.js"></script>
+    </head>
+
+    <body data-bs-theme="dark">
+
+      <div class="modal fade" id="settingsModal" data-bs-backdrop="static" data-bs-keyboard="false">
+        <div class="modal-dialog modal-dialog-centered">
+          <div class="modal-content">
+            <div class="modal-header">
+              <h1 id="titleHeader" class="modal-title w-100 text-center">te<strong>T</strong>ra</h1>
+            </div>
+            <div class="modal-body">
+              <form name="settingsForm" class="needs-validation" novalidate>
+                <fieldset id="keyBindFielset" class="row g-2 mb-3 align-items-center text-center">
+                  <legend class="text-start">Commandes</legend>
+                  <label for="moveLeftInput" title="Gauche" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center">
+                    <i class="bi bi-arrow-left"></i>
+                  </label>
+                  <div class="col-sm-4">
+                    <input name="moveLeft" id="moveLeftInput" type="text" class="form-control text-center" value="←" onclick="changeKey(this)" placeholder="Touche ?" required>
+                  </div>
+                  <div class="col-sm-4">
+                    <input name="moveRight" id="moveRightInput" type="text" class="form-control text-center" value="→" onclick="changeKey(this)" placeholder="Touche ?" required>
+                  </div>
+                  <label for="moveRightInput" title="Droite" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center">
+                    <i class="bi bi-arrow-right"></i>
+                  </label>
+                  <label for="rotateCounterclockwiseInput" title="Rotation anti-horaire" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center">
+                    <i class="bi bi-arrow-counterclockwise"></i>
+                  </label>
+                  <div class="col-sm-4">
+                    <input name="rotateCounterclockwise" id="rotateCounterclockwiseInput" type="text" class="form-control text-center" value="w" onclick="changeKey(this)" placeholder="Touche ?" required>
+                  </div>
+                  <div class="col-sm-4">
+                    <input name="rotateClockwise" id="rotateClockwiseInput" type="text" class="form-control text-center" value="↑" onclick="changeKey(this)" placeholder="Touche ?" required>
+                  </div>
+                  <label for="rotateClockwiseInput" title="Rotation horaire" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center">
+                    <i class="bi bi-arrow-clockwise"></i>
+                  </label>
+                  <label for="softDropInput" title="Chute lente" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center">
+                    <i class="bi bi-arrow-down-short"></i>
+                  </label>
+                  <div class="col-sm-4">
+                    <input name="softDrop" id="softDropInput" type="text" class="form-control text-center" value="↓" onclick="changeKey(this)" placeholder="Touche ?" required>
+                  </div>
+                  <div class="col-sm-4">
+                    <input name="hardDrop" id="hardDropInput" type="text" class="form-control text-center" value="Espace" onclick="changeKey(this)" placeholder="Touche ?" required>
+                  </div>
+                  <label for="hardDropInput" title="Chute rapide" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center">
+                    <i class="bi bi-download"></i>
+                  </label>
+                  <label for="holdInput" title="Échanger la pièce" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center">
+                    <i class="bi bi-arrow-left-right"></i>
+                  </label>
+                  <div class="col-sm-4">
+                    <input name="hold" id="holdInput" type="text" class="form-control text-center" value="c" onclick="changeKey(this)" placeholder="Touche ?" required>
+                  </div>
+                  <div class="col-sm-4">
+                    <input name="pause" id="pauseInput" type="text" class="form-control text-center" value="Échap" onclick="changeKey(this)" placeholder="Touche ?" required>
+                  </div>
+                  <label for="pauseInput" title="Pause" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center">
+                    <i class="bi bi-pause"></i>
+                  </label>
+                </fieldset>
+                <fieldset id="autorepearFieldset" class="row g-2 mb-3 align-items-center text-center">
+                  <legend class="text-start">Répétition automatique</legend>
+                  <label for="arrInput" class="col-sm-2 col-form-label" title="Automatic Repeat Rate : période de répétition de l'action">ARR</label>
+                  <div class="col-sm-4">
+                    <div class="input-group">
+                      <input name="arr" id="arrInput" type="number" class="form-control text-center" value="50" min="2" max="200" step="1">
+                      <div class="input-group-text">ms</div>
+                    </div>
+                  </div>
+                  <div class="col-sm-4">
+                    <div class="input-group">
+                      <input name="das" id="dasInput" type="number" class="form-control text-center" value="300" min="100" max="500" step="5">
+                      <div class="input-group-text">ms</div>
+                    </div>
+                  </div>
+                  <label for="dasInput" class="col-sm-2 col-form-label" title="Delayed AutoShift : délai initial avant répétition">DAS</label>
+                </fieldset>
+                <fieldset id="audioFieldset" class="row g-2 mb-3 align-items-center text-center">
+                  <legend class="text-start">Volume</legend>
+                  <label for="musicVolumeInput" class="col-sm-2 col-form-label">Musique</label>
+                  <div class="col-sm-4">
+                    <input name="musicVolume" id="musicVolumeInput" type="range" class="form-range" value=".5" min="0" max="1" step="0.01">
+                  </div>
+                  <div class="col-sm-4">
+                    <input name="sfxVolume" id="sfxVolumeInput" type="range" class="form-range" value=".5" min="0" max="1" step="0.01">
+                  </div>
+                  <label for="sfxVolumeInput" class="col-sm-2 col-form-label">SFX</label>
+                </fieldset>
+                <fieldset class="row g-2 mb-3 align-items-center text-center">
+                  <legend class="text-start">Partie</legend>
+                  <label for="levelInput" class="col-sm-2 col-form-label text-center">Niveau</label>
+                  <div class="col-sm-4">
+                    <input name="startLevel" id="levelInput" type="number" class="form-control text-center" value="1" min="1" max="15">
+                  </div>
+                  <div class="col-sm-4">
+                    <button id="resumeButton" type="submit" class="btn btn-primary w-100" autofocus>Jouer</button>
+                  </div>
+                </fieldset>
+              </form>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <span id="messagesSpan" class ="position-absolute"></span>
+
+      <div class="modal fade" id="statsModal" tabindex="-1">
+        <div class="modal-dialog modal-dialog-centered">
+          <div class="modal-dialog">
+            <div class="modal-content">
+              <div class="modal-header">
+                <h2 class="modal-title w-100 text-center">Fin</h2>
+                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
+              </div>
+              <div class="modal-body p-0">
+                <table class="table mb-0">
+                  <tr><th>Score         </th><td id="statsModalScoreCell">        </td><th>Niveau               </th><td id="statsModalLevelCell">          </td></tr>
+                  <tr><th>Meilleur score</th><td id="statsModalHighScoreCell">    </td><th>Temps                </th><td id="statsModalTimeCell">           </td></tr>
+                  <tr><th>Lignes        </th><td id="statsModaltotalClearedLines"></td><th>Lignes par minute    </th><td id="statsModaltotalClearedLinesPM"></td></tr>
+                  <tr><th>Quatris       </th><td id="statsModalNbQuatris">        </td><th>Plus long combo      </th><td id="statsModalMaxCombo">           </td></tr>
+                  <tr><th>Pirouettes    </th><td id="statsModalNbTSpin">          </td><th>Plus long bout à bout</th><td id="statsModalMaxB2B">             </td></tr>
+                </table>
+              </div>
+              <div class="modal-footer">
+                <button type="button" class="btn btn-primary" onclick="restart()">Rejouer ?</button>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ENjdO4Dr2bkBIFxQpeoTz1HIcje39Wm4jDKdf19U8gI4ddQ3GYNS7NTKfAdVQSZe" crossorigin="anonymous"></script>
+      <script src="app.js"></script>
+    </body>
+</html>
\ No newline at end of file
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..2b27632
--- /dev/null
+++ b/style.css
@@ -0,0 +1,127 @@
+body {
+    margin: 0
+}
+
+@supports (backdrop-filter: blur()) {
+    .card,
+    .modal-content {
+        background-color: rgba(33, 37, 41, 30%);
+        backdrop-filter: blur(15px);
+    }
+}
+
+#messagesSpan {
+    position: absolute;
+    top: 10%;
+    left: 50%;
+    transform: translate(-50%, 0);
+    color: rgba(255, 255, 255, 0.8);
+    text-shadow: 1px 1px rgba(0, 0, 0, 0.8);
+    font-size: 4vmin;
+    text-align: center;
+}
+
+#messagesSpan div {
+    opacity: 0;
+    overflow: hidden;
+}
+
+@keyframes show-level-animation {
+    from {
+        opacity: 0;
+        transform: translateY(200%);
+    }
+    50% {
+        opacity: 100%;
+        transform: translateY(0) scaleY(1);
+        line-height: var(--bs-body-line-height);
+    }
+    to {
+        opacity: 0;
+        transform: translateY(-100%) scaleY(0);
+        line-height: 0;
+    }
+}
+
+#messagesSpan div.show-level-animation {
+    animation: show-level-animation;
+    animation-timing-function: (0.4, 0, 0.6, 1);
+    animation-duration: 2s;
+}
+
+@keyframes zoom-in-animation {
+    from {
+        opacity: 0;
+        transform: scale3d(0.3, 0.3, 0.3);
+        line-height: var(--bs-body-line-height);
+
+    }
+    30% {
+        opacity: 1;
+        transform: scale3d(1, 1, 1);
+    }
+    80% {
+        opacity: 1;
+        transform: scale3d(1, 1, 1);
+        line-height: var(--bs-body-line-height);
+    }
+    to {
+        opacity: 0;
+        transform: scale3d(1.5, 0, 1);
+        line-height: 0;
+    }
+}
+
+@keyframes rotate-in-animation {
+    0% {
+        opacity:0;
+        transform:rotate(200deg);
+        line-height: var(--bs-body-line-height);
+    }
+    30% {
+        opacity:1;
+        transform:translateZ(0);
+        transform: scale3d(1, 1, 1);
+    }
+    80% {
+        opacity: 1;
+        transform: scale3d(1, 1, 1);
+        line-height: var(--bs-body-line-height);
+    }
+    to {
+        opacity: 0;
+        transform: scale3d(1.5, 0, 1);
+        line-height: 0;
+    }
+}
+
+#messagesSpan div.rotate-in-animation {
+    animation-name:  rotate-in-animation;
+    animation-timing-function: cubic-bezier(.25,.46,.45,.94);
+    animation-duration: 1s;
+}
+
+#messagesSpan div.zoom-in-animation {
+    animation-name: zoom-in-animation;
+    animation-timing-function: cubic-bezier(.25,.46,.45,.94);
+    transform-origin:center;
+    animation-duration: 1s;
+}
+
+@keyframes game-over-animation {
+    from {
+        opacity: 0;
+        transform: translateY(200%);
+    }
+    to {
+        opacity: 100%;
+        transform: translateY(0) scaleY(1);
+        line-height: var(--bs-body-line-height);
+    }
+}
+
+#messagesSpan div.game-over-animation {
+    animation: game-over-animation;
+    animation-timing-function: (0.4, 0, 0.6, 1);
+    animation-duration: 2s;
+}