├── .gitattributes ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── biome.json ├── index.html ├── package.json ├── public └── i.png ├── screenshot.png ├── src ├── constants.ts ├── entity.ts ├── index.ts ├── input.ts ├── keys.ts ├── mouse.ts ├── music.ts ├── sounds.ts ├── tilemap.ts ├── zzfx.ts └── zzfxm.ts ├── styles.css ├── tsconfig.json └── vite.config.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | package-lock.json -diff 3 | package-lock.json linguist-generated=true 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | package-lock.json 4 | .idea 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "[javascript]": { 4 | "editor.defaultFormatter": "biomejs.biome" 5 | }, 6 | "[typescript]": { 7 | "editor.defaultFormatter": "biomejs.biome" 8 | }, 9 | "[json]": { 10 | "editor.defaultFormatter": "biomejs.biome" 11 | }, 12 | "[jsonc]": { 13 | "editor.defaultFormatter": "biomejs.biome" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Cody Ebberson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JS13K Starter 2 | 3 | A starter template for the [js13kgames competition](https://js13kgames.com/). 4 | 5 | ![JS13k Starter](screenshot.png) 6 | 7 | Play live demo: 8 | 9 | This starter project uses [js13k-vite-plugins](https://github.com/codyebberson/js13k-vite-plugins) for js13k optimized tooling. See [js13k-vite-plugins](https://github.com/codyebberson/js13k-vite-plugins) for more information about tooling and configuration. 10 | 11 | This demo currently builds to about 4 kB. Don't let this initial size overly concern you! The magic of the js13k compression process means that the final zipped size doesn't simply grow linearly with every line of code you add – the results can be quite unpredictable. The most important thing is to focus on creating your game. Rest assured, this starter equips you with the same powerful optimization toolchain that many js13k veterans rely on. 12 | 13 | ## Usage 14 | 15 | ### Install 16 | 17 | Clone and install dependencies: 18 | 19 | ```bash 20 | git clone git@github.com:codyebberson/js13k-starter.git 21 | cd js13k-starter 22 | npm i 23 | ``` 24 | 25 | ### Dev server 26 | 27 | Start the dev server with hot reload: 28 | 29 | ```bash 30 | npm run dev 31 | ``` 32 | 33 | Open your web browser to 34 | 35 | ### Production build 36 | 37 | Create a final production build: 38 | 39 | ```bash 40 | npm run build 41 | ``` 42 | 43 | ### Preview production build 44 | 45 | After building, you can preview the production build with Vite's built-in server: 46 | 47 | ```bash 48 | npm run preview 49 | ``` 50 | 51 | ## Acknowledgements 52 | 53 | [Frank Force](https://twitter.com/KilledByAPixel) for [ZzFX](https://github.com/KilledByAPixel/ZzFX) 54 | 55 | [Keith Clark](https://twitter.com/keithclarkcouk) and [Frank Force](https://twitter.com/KilledByAPixel) for [ZzFXM](https://keithclark.github.io/ZzFXM/) 56 | 57 | [Kang Seonghoon](https://mearie.org/) for [Roadroller](https://lifthrasiir.github.io/roadroller/) 58 | 59 | [Rob Louie](https://github.com/roblouie) for Roadroller configuration recommendations 60 | 61 | [Salvatore Previti](https://github.com/SalvatorePreviti) for Terser configuration recommendations 62 | 63 | [Kenney](https://kenney.nl/) for [Pixel Platformer](https://kenney.nl/assets/pixel-platformer) graphics 64 | 65 | [Andrzej Mazur](https://end3r.com/) for organizing js13k 66 | 67 | ## License 68 | 69 | Code: MIT 70 | 71 | Graphics: Creative Commons CC0 1.0 Universal 72 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.1.3/schema.json", 3 | "assist": { "actions": { "source": { "organizeImports": "on" } } }, 4 | "formatter": { 5 | "enabled": true, 6 | "indentStyle": "space", 7 | "indentWidth": 2, 8 | "lineWidth": 120 9 | }, 10 | "linter": { 11 | "enabled": true, 12 | "rules": { 13 | "recommended": true, 14 | "complexity": { 15 | "noCommaOperator": "off" 16 | }, 17 | "style": { 18 | "noParameterAssign": "off", 19 | "useImportType": "off", 20 | "useTemplate": "off" 21 | }, 22 | "suspicious": { 23 | "noAssignInExpressions": "off", 24 | "noExplicitAny": "off", 25 | "noSparseArray": "off" 26 | } 27 | } 28 | }, 29 | "javascript": { 30 | "formatter": { 31 | "enabled": true, 32 | "quoteStyle": "single" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | JS13K Demo 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "js13k-starter", 3 | "type": "module", 4 | "version": "0.0.2", 5 | "license": "MIT", 6 | "main": "src/index.ts", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "fix": "biome check --write --unsafe .", 11 | "preview": "vite preview", 12 | "lint": "biome lint ." 13 | }, 14 | "devDependencies": { 15 | "@biomejs/biome": "2.1.3", 16 | "js13k-vite-plugins": "0.4.0", 17 | "terser": "5.43.1", 18 | "typescript": "5.9.2", 19 | "vite": "7.0.6" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /public/i.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codyebberson/js13k-starter/4f7c3aa5ca6c534513b41ecc0e0e64f3f1fd23e2/public/i.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codyebberson/js13k-starter/4f7c3aa5ca6c534513b41ecc0e0e64f3f1fd23e2/screenshot.png -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const WIDTH = 480; 2 | export const HEIGHT = 256; 3 | export const CENTER_X = WIDTH / 2; 4 | export const CENTER_Y = HEIGHT / 2; 5 | export const PLAYER_ACCELERATION = 0.0015; 6 | export const PLAYER_DECCELERATION = 0.002; 7 | export const PLAYER_MAX_SPEED = 0.12; 8 | export const PLAYER_JUMP_POWER = 0.18; 9 | export const GRAVITY = 0.0008; 10 | export const FLOATY_GRAVITY = 0.0004; 11 | export const TILEMAP_WIDTH = 128; 12 | export const TILEMAP_HEIGHT = 16; 13 | export const TILE_SIZE = 16; 14 | export const HALF_TILE_SIZE = TILE_SIZE / 2; 15 | -------------------------------------------------------------------------------- /src/entity.ts: -------------------------------------------------------------------------------- 1 | export const ENTITY_TYPE_PLAYER = 0; 2 | export const ENTITY_TYPE_COIN = 1; 3 | export const ENTITY_TYPE_JUMPPAD = 2; 4 | export const ENTITY_TYPE_WALKING_ENEMY = 3; 5 | 6 | export class Entity { 7 | entityType: number; 8 | x: number; 9 | y: number; 10 | dx: number; 11 | dy: number; 12 | direction: number; 13 | grounded: boolean; 14 | frame: number; 15 | health: number; 16 | cooldown: number; 17 | 18 | constructor(entityType: number, x: number, y: number, dx = 0, dy = 0) { 19 | this.entityType = entityType; 20 | this.x = x; 21 | this.y = y; 22 | this.dx = dx; 23 | this.dy = dy; 24 | this.direction = 1; 25 | this.grounded = false; 26 | this.frame = 0; 27 | this.health = 100; 28 | this.cooldown = 0; 29 | entities.push(this); 30 | } 31 | 32 | distance(other: Entity): number { 33 | return Math.hypot(this.x - other.x, this.y - other.y); 34 | } 35 | } 36 | 37 | export const entities: Entity[] = []; 38 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FLOATY_GRAVITY, 3 | GRAVITY, 4 | HALF_TILE_SIZE, 5 | HEIGHT, 6 | PLAYER_ACCELERATION, 7 | PLAYER_JUMP_POWER, 8 | PLAYER_MAX_SPEED, 9 | TILE_SIZE, 10 | TILEMAP_HEIGHT, 11 | TILEMAP_WIDTH, 12 | WIDTH, 13 | } from './constants'; 14 | import { 15 | ENTITY_TYPE_COIN, 16 | ENTITY_TYPE_JUMPPAD, 17 | ENTITY_TYPE_PLAYER, 18 | ENTITY_TYPE_WALKING_ENEMY, 19 | entities, 20 | } from './entity'; 21 | import { initKeys, KEY_A, KEY_D, KEY_LEFT, KEY_RIGHT, KEY_Z, keys, updateKeys } from './keys'; 22 | import { initMouse, updateMouse } from './mouse'; 23 | import { coinSound, hurtSound, jumpPadSound, jumpSound } from './sounds'; 24 | import { collisionDetectionEntityToTile, getTile, initTileMap } from './tilemap'; 25 | import { zzfx } from './zzfx'; 26 | 27 | const canvas = document.querySelector('#c') as HTMLCanvasElement; 28 | const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; 29 | 30 | const skyGradient = ctx.createLinearGradient(0, 0, 0, HEIGHT); 31 | skyGradient.addColorStop(0, '#dff6f5'); 32 | skyGradient.addColorStop(1, '#a4c6f1'); 33 | 34 | const image = new Image(); 35 | image.src = 'i.png'; 36 | 37 | const player = initTileMap(); 38 | 39 | let windowTime = 0; 40 | let dt = 0; 41 | let gameTime = 0; 42 | let score = 0; 43 | let viewportX = 0; 44 | 45 | initKeys(canvas); 46 | initMouse(canvas); 47 | 48 | function gameLoop(newTime: number): void { 49 | requestAnimationFrame(gameLoop); 50 | 51 | if (player.health > 0) { 52 | dt = Math.min(newTime - windowTime, 1000 / 30); 53 | gameTime += dt; 54 | 55 | updateKeys(); 56 | updateMouse(); 57 | handleInput(); 58 | updateEntities(); 59 | collisionDetection(); 60 | updateCamera(); 61 | } 62 | 63 | render(); 64 | windowTime = newTime; 65 | dt = 0; 66 | } 67 | 68 | function handleInput(): void { 69 | if (keys[KEY_LEFT].down || keys[KEY_A].down) { 70 | player.dx -= dt * PLAYER_ACCELERATION; 71 | player.direction = -1; 72 | } else if (keys[KEY_RIGHT].down || keys[KEY_D].down) { 73 | player.dx += dt * PLAYER_ACCELERATION; 74 | player.direction = 1; 75 | } else { 76 | player.dx = 0; 77 | } 78 | 79 | if (keys[KEY_Z].downCount === 1 && player.grounded) { 80 | player.dy = -PLAYER_JUMP_POWER; 81 | zzfx(...jumpSound); 82 | } 83 | 84 | if (player.dx > PLAYER_MAX_SPEED) { 85 | player.dx = PLAYER_MAX_SPEED; 86 | } else if (player.dx < -PLAYER_MAX_SPEED) { 87 | player.dx = -PLAYER_MAX_SPEED; 88 | } 89 | } 90 | 91 | function updateEntities(): void { 92 | for (let i = entities.length - 1; i >= 0; i--) { 93 | const entity = entities[i]; 94 | 95 | if (entity === player && keys[KEY_Z].down) { 96 | player.dy += dt * FLOATY_GRAVITY; 97 | } else if (entity.entityType !== ENTITY_TYPE_COIN) { 98 | entity.dy += dt * GRAVITY; 99 | } 100 | 101 | if (entity.entityType === ENTITY_TYPE_WALKING_ENEMY) { 102 | entity.dx = entity.direction * 0.03; 103 | } 104 | 105 | entity.x += dt * entity.dx; 106 | entity.y += dt * entity.dy; 107 | entity.cooldown--; 108 | 109 | // Clear out dead entities 110 | if (entity.health <= 0) { 111 | entities.splice(i, 1); 112 | } 113 | } 114 | } 115 | 116 | function collisionDetection(): void { 117 | collisionDetectionEntityToTile(); 118 | collisionDetectionEntityToEntity(); 119 | } 120 | 121 | function collisionDetectionEntityToEntity(): void { 122 | for (const entity of entities) { 123 | for (const other of entities) { 124 | if (entity !== other && entity.distance(other) < TILE_SIZE) { 125 | if (entity === player && other.entityType === ENTITY_TYPE_COIN) { 126 | score += 100; 127 | other.health = 0; 128 | zzfx(...coinSound); 129 | } 130 | if (entity === player && other.entityType === ENTITY_TYPE_JUMPPAD) { 131 | player.y = Math.min(player.y, other.y - 8); 132 | player.dx = 0; 133 | player.dy = -PLAYER_JUMP_POWER * 2; 134 | zzfx(...jumpPadSound); 135 | } 136 | if (entity === player && other.entityType === ENTITY_TYPE_WALKING_ENEMY) { 137 | if (player.y + HALF_TILE_SIZE < other.y) { 138 | // If the player is at least half a tile above the enemy, kill the enemy 139 | other.health -= 100; 140 | player.dy = -PLAYER_JUMP_POWER; 141 | zzfx(...jumpSound); 142 | } else { 143 | // Otherwise hurt the player 144 | player.health -= 10; 145 | player.dy = -0.25 * PLAYER_JUMP_POWER; 146 | 147 | // Push the player away from the enemy 148 | if (player.x < other.x) { 149 | player.x = other.x - TILE_SIZE - HALF_TILE_SIZE; 150 | } else { 151 | player.x = other.x + TILE_SIZE + HALF_TILE_SIZE; 152 | } 153 | zzfx(...hurtSound); 154 | } 155 | } 156 | } 157 | } 158 | } 159 | } 160 | 161 | function updateCamera(): void { 162 | if (player.x - viewportX > 300) { 163 | viewportX = player.x - 300; 164 | } else if (viewportX + WIDTH - player.x > 300) { 165 | viewportX = player.x + 300 - WIDTH; 166 | } 167 | 168 | if (viewportX < 0) { 169 | viewportX = 0; 170 | } 171 | 172 | if (viewportX + WIDTH > TILEMAP_WIDTH * TILE_SIZE) { 173 | viewportX = TILEMAP_WIDTH * TILE_SIZE - WIDTH; 174 | } 175 | } 176 | 177 | function render(): void { 178 | clearScreen(); 179 | drawTileMap(); 180 | drawEntities(); 181 | drawOverlay(); 182 | } 183 | 184 | function clearScreen(): void { 185 | ctx.fillStyle = skyGradient; 186 | ctx.fillRect(0, 0, WIDTH, HEIGHT); 187 | } 188 | 189 | function drawTileMap(): void { 190 | for (let y = 0; y < TILEMAP_HEIGHT; y++) { 191 | for (let x = 0; x < TILEMAP_WIDTH; x++) { 192 | const tile = getTile(x, y); 193 | if (tile > 0) { 194 | const tx = (tile - 1) * TILE_SIZE; 195 | const ty = 24; 196 | ctx.drawImage( 197 | image, 198 | tx, 199 | ty, 200 | TILE_SIZE, 201 | TILE_SIZE, 202 | Math.floor(x * TILE_SIZE - viewportX), 203 | y * TILE_SIZE, 204 | TILE_SIZE, 205 | TILE_SIZE, 206 | ); 207 | } 208 | } 209 | } 210 | } 211 | 212 | function drawEntities(): void { 213 | for (const entity of entities) { 214 | ctx.save(); 215 | ctx.translate(Math.floor(entity.x - viewportX + HALF_TILE_SIZE), Math.floor(entity.y + HALF_TILE_SIZE)); 216 | ctx.scale(entity.direction, 1); 217 | let sx = 0; 218 | if (entity.entityType === ENTITY_TYPE_PLAYER) { 219 | const walking = Math.abs(entity.dx) > 0.01; 220 | sx = !entity.grounded ? 48 : walking ? 16 + (entity.frame | 0) * TILE_SIZE : 0; 221 | } else if (entity.entityType === ENTITY_TYPE_COIN) { 222 | sx = 64 + (entity.frame | 0) * TILE_SIZE; 223 | } else if (entity.entityType === ENTITY_TYPE_JUMPPAD) { 224 | sx = 96; 225 | } else if (entity.entityType === ENTITY_TYPE_WALKING_ENEMY) { 226 | sx = 112 + (entity.frame | 0) * TILE_SIZE; 227 | } 228 | ctx.drawImage(image, sx, 8, TILE_SIZE, TILE_SIZE, -HALF_TILE_SIZE, -HALF_TILE_SIZE, TILE_SIZE, TILE_SIZE); 229 | ctx.restore(); 230 | entity.frame += dt * 0.005; 231 | if (entity.frame >= 2) { 232 | entity.frame = 0; 233 | } 234 | } 235 | } 236 | 237 | function drawOverlay(): void { 238 | ctx.fillStyle = '#000'; 239 | ctx.fillRect(0, 0, WIDTH, 16); 240 | 241 | drawString('HEALTH ' + player.health, 4, 6); 242 | drawString('SCORE ' + score, 150, 6); 243 | drawString('TIME ' + ((gameTime / 1000) | 0), 300, 6); 244 | } 245 | 246 | function drawString(str: string, x: number, y: number): void { 247 | for (let i = 0; i < str.length; i++) { 248 | const charCode = str.charCodeAt(i); 249 | const charIndex = charCode < 65 ? charCode - 48 : charCode - 55; 250 | ctx.drawImage(image, charIndex * 6, 0, 6, 6, x, y, 6, 6); 251 | x += 6; 252 | } 253 | } 254 | 255 | requestAnimationFrame(gameLoop); 256 | -------------------------------------------------------------------------------- /src/input.ts: -------------------------------------------------------------------------------- 1 | export interface Input { 2 | down: boolean; 3 | downCount: number; 4 | upCount: number; 5 | } 6 | 7 | /** 8 | * Creates a new input. 9 | */ 10 | export const newInput = (): Input => ({ down: false, downCount: 0, upCount: 2 }); 11 | 12 | /** 13 | * Updates the up/down counts for an input. 14 | * @param input 15 | */ 16 | export function updateInput(input: Input): void { 17 | if (input.down) { 18 | input.downCount++; 19 | input.upCount = 0; 20 | } else { 21 | input.downCount = 0; 22 | input.upCount++; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/keys.ts: -------------------------------------------------------------------------------- 1 | import { type Input, newInput, updateInput } from './input'; 2 | 3 | export const KEY_LEFT = 37; 4 | export const KEY_UP = 38; 5 | export const KEY_RIGHT = 39; 6 | export const KEY_DOWN = 40; 7 | export const KEY_A = 65; 8 | export const KEY_D = 68; 9 | export const KEY_S = 83; 10 | export const KEY_W = 87; 11 | export const KEY_Z = 90; 12 | 13 | const KEY_COUNT = 256; 14 | 15 | /** 16 | * Array of keyboard keys. 17 | */ 18 | export const keys: Input[] = new Array(KEY_COUNT); 19 | 20 | /** 21 | * Initializes the keyboard. 22 | * @param el The HTML element to listen on. 23 | */ 24 | export function initKeys(el: HTMLElement): void { 25 | for (let i = 0; i < KEY_COUNT; i++) { 26 | keys[i] = newInput(); 27 | } 28 | 29 | el.addEventListener('click', handleClick); 30 | el.addEventListener('keydown', (e) => setKey(e, true)); 31 | el.addEventListener('keyup', (e) => setKey(e, false)); 32 | } 33 | 34 | function handleClick(e: MouseEvent): void { 35 | e.preventDefault(); 36 | e.stopPropagation(); 37 | (e.currentTarget as HTMLElement).focus(); 38 | } 39 | 40 | function setKey(e: KeyboardEvent, state: boolean): void { 41 | e.preventDefault(); 42 | e.stopPropagation(); 43 | keys[e.keyCode].down = state; 44 | } 45 | 46 | export function updateKeys(): void { 47 | keys.forEach(updateInput); 48 | } 49 | -------------------------------------------------------------------------------- /src/mouse.ts: -------------------------------------------------------------------------------- 1 | import { newInput, updateInput } from './input'; 2 | 3 | export const mouse = { 4 | /** 5 | * Mouse x coordinate. 6 | */ 7 | x: 0, 8 | 9 | /** 10 | * Mouse y coordinate. 11 | */ 12 | y: 0, 13 | 14 | /** 15 | * Mouse buttons 16 | */ 17 | buttons: [newInput(), newInput(), newInput()], 18 | }; 19 | 20 | /** 21 | * Initializes the keyboard. 22 | * @param el The HTML element to listen on. 23 | */ 24 | export function initMouse(el: HTMLElement): void { 25 | el.addEventListener('mousedown', (e) => { 26 | mouse.buttons[e.button].down = true; 27 | }); 28 | el.addEventListener('mouseup', (e) => { 29 | mouse.buttons[e.button].down = false; 30 | }); 31 | el.addEventListener('mousemove', (e) => { 32 | mouse.x = e.pageX - el.offsetLeft; 33 | mouse.y = e.pageY - el.offsetTop; 34 | }); 35 | } 36 | 37 | /** 38 | * Updates all mouse button states. 39 | */ 40 | export function updateMouse(): void { 41 | mouse.buttons.forEach(updateInput); 42 | } 43 | -------------------------------------------------------------------------------- /src/music.ts: -------------------------------------------------------------------------------- 1 | import { zzfxM } from './zzfxm'; 2 | 3 | export const music = zzfxM( 4 | [ 5 | [0.4, 0, , 0.1, 0.25, 0.25, 2, 0.2, , , , , , 0.1, , , 0.06, , 0.1, 0.21], 6 | [, 0, 440, , , 0.15, 2, 0.2, -0.1, , 9, 0.02, , 0.1, 0.12, , 0.06], 7 | [0.4, 0, , 0.1, 1, 0.25, 2, 0.2, , , , , , 0.1, , , 0.06, , 0.1, 0.21], 8 | ], 9 | [ 10 | [ 11 | [ 12 | , 13 | , 14 | 8, 15 | , 16 | , 17 | , 18 | , 19 | , 20 | , 21 | , 22 | 11, 23 | , 24 | , 25 | , 26 | 15, 27 | , 28 | , 29 | , 30 | 14, 31 | , 32 | , 33 | , 34 | 7, 35 | , 36 | , 37 | , 38 | , 39 | , 40 | , 41 | , 42 | , 43 | , 44 | , 45 | , 46 | 6, 47 | , 48 | , 49 | , 50 | , 51 | , 52 | , 53 | , 54 | 6, 55 | , 56 | , 57 | , 58 | 9, 59 | , 60 | 14, 61 | , 62 | 13, 63 | , 64 | , 65 | , 66 | 5, 67 | , 68 | , 69 | , 70 | , 71 | , 72 | , 73 | , 74 | , 75 | , 76 | , 77 | ], 78 | [ 79 | 1, 80 | , 81 | 8, 82 | 11, 83 | 15, 84 | 16, 85 | 8, 86 | 11, 87 | 15, 88 | 16, 89 | 8, 90 | 11, 91 | 15, 92 | 16, 93 | 8, 94 | 11, 95 | 15, 96 | 16, 97 | 7, 98 | 10, 99 | 15, 100 | 16, 101 | 7, 102 | 10, 103 | 15, 104 | 16, 105 | 10, 106 | 15, 107 | 16, 108 | 7, 109 | 10, 110 | 15, 111 | 16, 112 | 6, 113 | 9, 114 | 15, 115 | 16, 116 | 6, 117 | 9, 118 | 15, 119 | 16, 120 | 6, 121 | 9, 122 | 15, 123 | 16, 124 | 6, 125 | 9, 126 | 15, 127 | 16, 128 | 5, 129 | 8, 130 | 15, 131 | 16, 132 | 5, 133 | 8, 134 | 15, 135 | 16, 136 | 5, 137 | 8, 138 | 15, 139 | 16, 140 | 5, 141 | 8, 142 | 15, 143 | 16, 144 | ], 145 | ], 146 | [ 147 | [2, , 4, , , , , , , , , , , , , , , , 4, , , , , , , , , , , , , , , , 3, , , , , , , , , , , , , , , , , , , ,], 148 | [ 149 | 1, 150 | , 151 | 4, 152 | 8, 153 | 13, 154 | 15, 155 | 4, 156 | 8, 157 | 13, 158 | 15, 159 | 4, 160 | 8, 161 | 13, 162 | 15, 163 | 4, 164 | 8, 165 | 13, 166 | 15, 167 | 3, 168 | 8, 169 | 13, 170 | 15, 171 | 3, 172 | 8, 173 | 13, 174 | 15, 175 | 3, 176 | 8, 177 | 13, 178 | 15, 179 | 3, 180 | 8, 181 | 13, 182 | 15, 183 | 1, 184 | 7, 185 | 10, 186 | 13, 187 | 7, 188 | 10, 189 | 13, 190 | 16, 191 | 10, 192 | 13, 193 | 16, 194 | 13, 195 | 16, 196 | 19, 197 | 16, 198 | 19, 199 | 22, 200 | 19, 201 | 22, 202 | 25, 203 | ], 204 | ], 205 | ], 206 | [0, 1], 207 | 90, 208 | ); 209 | -------------------------------------------------------------------------------- /src/sounds.ts: -------------------------------------------------------------------------------- 1 | // Sounds generated with ZzFX: https://killedbyapixel.github.io/ZzFX/ 2 | // Find a sound you like, then copy the parameters into the sound array. 3 | 4 | export const coinSound = [, 0, 1267, 0.01, 0.09, , 1, 1.5, , , 400, 0.08, , , , , , 0.3, 0.02]; 5 | 6 | export const jumpSound = [, 0, 200, 0.02, 0.01, 0.03, , 1.73, 5, , , , , , , , , 0.5, 0.09]; 7 | 8 | export const jumpPadSound = [, 0, 175, , 0.1, , , 0.28, 10, 2, , , , , , , , 0.59, 0.05]; 9 | 10 | export const hurtSound = [, 0, 466, , 0.08, 0.05, , 0.27, -2.5, , , , , , , , , 0.69, 0.09]; 11 | -------------------------------------------------------------------------------- /src/tilemap.ts: -------------------------------------------------------------------------------- 1 | import { HEIGHT, TILE_SIZE, TILEMAP_HEIGHT, TILEMAP_WIDTH } from './constants'; 2 | import { 3 | ENTITY_TYPE_COIN, 4 | ENTITY_TYPE_JUMPPAD, 5 | ENTITY_TYPE_PLAYER, 6 | ENTITY_TYPE_WALKING_ENEMY, 7 | Entity, 8 | entities, 9 | } from './entity'; 10 | 11 | // Simple tile map 12 | // 13 | // In this example, the tile map is a 2D array of numbers 14 | // P = player 15 | // C = coin 16 | // J = jump pad 17 | // W = walking enemy 18 | // 1-9 = different tiles 19 | // 20 | // You can use whatever convention you like for creating the map 21 | // 22 | // Some other ideas: 23 | // - Use a tool like Tiled to create maps 24 | // - Load the map from image data, to take advantage of PNG compression 25 | // - Procedurally generate the map 26 | const tileMapSource: string[] = [ 27 | ' 454556555556 ', 28 | ' 454556555556 ', 29 | ' 454556555556 ', 30 | ' 457889555559 ', 31 | ' 45555555889 ', 32 | ' 78885556 1223 ', 33 | ' 7889 ', 34 | ' P C CC CC ', 35 | ' C C J 123 ', 36 | ' 1223 122223 C 1223 456 W W W W W W W W ', 37 | ' 45553 W 122222555556 455223 1223 1223 1223 1223 1223 1223 1223 12', 38 | ' 12222455553 15555555555553 155555522225555222255552222555522225555222255552222555522225555222255', 39 | ' 455554555553 CC 1555555123555553 455555555555555555555555555555555555555555555555555555555555555555555', 40 | ' 12455554555556 C C 1555555545122355523 1223 J 1512355555555555555555555555555555555555555555555555555555555555555555', 41 | ' 122355554555556J 1555555554545565555522255552225545655555555555555555555555555555555555555555555555555555555555555555', 42 | '22222222555545555562222222225555555554545565555555551223555545655555555555555555555555555555555555555555555555555555555555555555', 43 | ]; 44 | 45 | const tileMap: number[][] = []; 46 | 47 | export function initTileMap(): Entity { 48 | let player: Entity | undefined; 49 | 50 | for (let y = 0; y < TILEMAP_HEIGHT; y++) { 51 | const row = []; 52 | for (let x = 0; x < TILEMAP_WIDTH; x++) { 53 | let tile = 0; 54 | let c = ''; 55 | 56 | switch ((c = tileMapSource[y].charAt(x))) { 57 | case 'P': 58 | player = new Entity(ENTITY_TYPE_PLAYER, x * TILE_SIZE, y * TILE_SIZE); 59 | break; 60 | case 'C': 61 | new Entity(ENTITY_TYPE_COIN, x * TILE_SIZE, y * TILE_SIZE); 62 | break; 63 | case 'J': 64 | new Entity(ENTITY_TYPE_JUMPPAD, x * TILE_SIZE, y * TILE_SIZE); 65 | break; 66 | case 'W': 67 | new Entity(ENTITY_TYPE_WALKING_ENEMY, x * TILE_SIZE, y * TILE_SIZE); 68 | break; 69 | default: 70 | // Convert ASCII to tile index 71 | // 48 is the ASCII code for '0' 72 | // See ASCII chart for more details: https://en.wikipedia.org/wiki/ASCII 73 | tile = Math.max(0, c.charCodeAt(0) - 48); 74 | break; 75 | } 76 | row.push(tile); 77 | } 78 | tileMap.push(row); 79 | } 80 | 81 | return player as Entity; 82 | } 83 | 84 | export function getTile(x: number, y: number): number { 85 | if (x < 0 || x >= TILEMAP_WIDTH || y < 0 || y >= TILEMAP_HEIGHT) { 86 | return 1; 87 | } 88 | return tileMap[y | 0][x | 0]; 89 | } 90 | 91 | export function collisionDetectionEntityToTile(): void { 92 | for (const entity of entities) { 93 | entity.grounded = false; 94 | 95 | if (entity.dy < 0) { 96 | if (getTile((entity.x + 8) / TILE_SIZE, entity.y / TILE_SIZE)) { 97 | entity.y = Math.floor(entity.y / TILE_SIZE) * TILE_SIZE + TILE_SIZE; 98 | entity.dy = 0; 99 | } 100 | } 101 | 102 | if (entity.dy > 0) { 103 | if (getTile((entity.x + 4) / TILE_SIZE, entity.y / TILE_SIZE + 1)) { 104 | entity.y = Math.floor(entity.y / TILE_SIZE) * TILE_SIZE; 105 | entity.dy = 0; 106 | entity.grounded = true; 107 | } 108 | if (getTile((entity.x + 12) / TILE_SIZE, entity.y / TILE_SIZE + 1)) { 109 | entity.y = Math.floor(entity.y / TILE_SIZE) * TILE_SIZE; 110 | entity.dy = 0; 111 | entity.grounded = true; 112 | } 113 | } 114 | 115 | if (getTile(entity.x / TILE_SIZE, (entity.y + 8) / TILE_SIZE)) { 116 | entity.x = Math.floor(entity.x / TILE_SIZE) * TILE_SIZE + TILE_SIZE; 117 | entity.dx = 0; 118 | if (entity.entityType !== ENTITY_TYPE_PLAYER) { 119 | entity.direction = 1; 120 | } 121 | } 122 | 123 | if (getTile(entity.x / TILE_SIZE + 1, (entity.y + 8) / TILE_SIZE)) { 124 | entity.x = Math.floor(entity.x / TILE_SIZE) * TILE_SIZE; 125 | entity.dx = 0; 126 | if (entity.entityType !== ENTITY_TYPE_PLAYER) { 127 | entity.direction = -1; 128 | } 129 | } 130 | 131 | // Prevent entities from falling through the floor 132 | // This can be removed if you have confidence that your maps always have a floor 133 | if (entity.y > HEIGHT - 32) { 134 | entity.y = HEIGHT - 32; 135 | entity.dy = 0; 136 | entity.grounded = true; 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/zzfx.ts: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | ZzFX - Zuper Zmall Zound Zynth v1.1.8 4 | By Frank Force 2019 5 | https://github.com/KilledByAPixel/ZzFX 6 | 7 | ZzFX Features 8 | 9 | - Tiny synth engine with 20 controllable parameters. 10 | - Play sounds via code, no need for sound assed files! 11 | - Compatible with most modern web browsers. 12 | - Small code footprint, the micro version is under 1 kilobyte. 13 | - Can produce a huge variety of sound effect types. 14 | - Sounds can be played with a short call. zzfx(...[,,,,.1,,,,9]) 15 | - A small bit of randomness appied to sounds when played. 16 | - Use ZZFX.GetNote to get frequencies on a standard diatonic scale. 17 | - Sounds can be saved out as wav files for offline playback. 18 | - No additional libraries or dependencies are required. 19 | 20 | */ 21 | /* 22 | 23 | ZzFX MIT License 24 | 25 | Copyright (c) 2019 - Frank Force 26 | 27 | Permission is hereby granted, free of charge, to any person obtaining a copy 28 | of this software and associated documentation files (the "Software"), to deal 29 | in the Software without restriction, including without limitation the rights 30 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 31 | copies of the Software, and to permit persons to whom the Software is 32 | furnished to do so, subject to the following conditions: 33 | 34 | The above copyright notice and this permission notice shall be included in all 35 | copies or substantial portions of the Software. 36 | 37 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 38 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 39 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 40 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 41 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 42 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 43 | SOFTWARE. 44 | 45 | */ 46 | 47 | /** 48 | * Master volume scale. 49 | */ 50 | export const zzfxV = 0.3; 51 | 52 | /** 53 | * Sample rate for audio. 54 | */ 55 | export const zzfxR = 44100; 56 | 57 | /** 58 | * Create shared audio context. 59 | */ 60 | export const zzfxX = new AudioContext(); 61 | 62 | /** 63 | * Play a sound from zzfx paramerters. 64 | */ 65 | export function zzfx(...parameters: (number | undefined)[]): AudioBufferSourceNode { 66 | return zzfxP(zzfxG(...parameters)); 67 | } 68 | 69 | /** 70 | * Play an array of samples. 71 | */ 72 | export function zzfxP(...samples: number[][]): AudioBufferSourceNode { 73 | const buffer = zzfxX.createBuffer(samples.length, samples[0].length, zzfxR); 74 | const source = zzfxX.createBufferSource(); 75 | 76 | samples.map((d, i) => buffer.getChannelData(i).set(d)); 77 | source.buffer = buffer; 78 | source.connect((zzfxX as AudioContext).destination); 79 | source.start(); 80 | return source; 81 | } 82 | 83 | /** 84 | * Build an array of samples. 85 | */ 86 | export function zzfxG( 87 | volume = 1, 88 | randomness = 0.05, 89 | frequency = 220, 90 | attack = 0, 91 | sustain = 0, 92 | release = 0.1, 93 | shape = 0, 94 | shapeCurve = 1, 95 | slide = 0, 96 | deltaSlide = 0, 97 | pitchJump = 0, 98 | pitchJumpTime = 0, 99 | repeatTime = 0, 100 | noise = 0, 101 | modulation = 0, 102 | bitCrush = 0, 103 | delay = 0, 104 | sustainVolume = 1, 105 | decay = 0, 106 | tremolo = 0, 107 | ): number[] { 108 | // init parameters 109 | const PI2 = Math.PI * 2; 110 | const sampleRate = zzfxR; 111 | const sign = (v: number): number => (v > 0 ? 1 : -1); 112 | const startSlide = (slide *= (500 * PI2) / sampleRate / sampleRate); 113 | const b = []; 114 | 115 | let startFrequency = (frequency *= ((1 + randomness * 2 * Math.random() - randomness) * PI2) / sampleRate); 116 | let t = 0; 117 | let tm = 0; 118 | let i = 0; 119 | let j = 1; 120 | let r = 0; 121 | let c = 0; 122 | let s = 0; 123 | let f: number; 124 | let length: number; 125 | 126 | // scale by sample rate 127 | attack = attack * sampleRate + 9; // minimum attack to prevent pop 128 | decay *= sampleRate; 129 | sustain *= sampleRate; 130 | release *= sampleRate; 131 | delay *= sampleRate; 132 | deltaSlide *= (500 * PI2) / sampleRate ** 3; 133 | modulation *= PI2 / sampleRate; 134 | pitchJump *= PI2 / sampleRate; 135 | pitchJumpTime *= sampleRate; 136 | repeatTime = (repeatTime * sampleRate) | 0; 137 | 138 | // generate waveform 139 | for (length = (attack + decay + sustain + release + delay) | 0; i < length; b[i++] = s) { 140 | if (!(++c % ((bitCrush * 100) | 0))) { 141 | // bit crush 142 | s = shape 143 | ? shape > 1 144 | ? shape > 2 145 | ? shape > 3 // wave shape 146 | ? Math.sin((t % PI2) ** 3) // 4 noise 147 | : Math.max(Math.min(Math.tan(t), 1), -1) // 3 tan 148 | : 1 - (((((2 * t) / PI2) % 2) + 2) % 2) // 2 saw 149 | : 1 - 4 * Math.abs(Math.round(t / PI2) - t / PI2) // 1 triangle 150 | : Math.sin(t); // 0 sin 151 | 152 | s = 153 | (repeatTime 154 | ? 1 - tremolo + tremolo * Math.sin((PI2 * i) / repeatTime) // tremolo 155 | : 1) * 156 | sign(s) * 157 | Math.abs(s) ** shapeCurve * // curve 0=square, 2=pointy 158 | volume * 159 | zzfxV * // envelope 160 | (i < attack 161 | ? i / attack // attack 162 | : i < attack + decay // decay 163 | ? 1 - ((i - attack) / decay) * (1 - sustainVolume) // decay falloff 164 | : i < attack + decay + sustain // sustain 165 | ? sustainVolume // sustain volume 166 | : i < length - delay // release 167 | ? ((length - i - delay) / release) * // release falloff 168 | sustainVolume // release volume 169 | : 0); // post release 170 | 171 | s = delay 172 | ? s / 2 + 173 | (delay > i 174 | ? 0 // delay 175 | : ((i < length - delay ? 1 : (length - i) / delay) * // release delay 176 | b[(i - delay) | 0]) / 177 | 2) 178 | : s; // sample delay 179 | } 180 | 181 | f = 182 | (frequency += slide += deltaSlide) * // frequency 183 | Math.cos(modulation * tm++); // modulation 184 | t += f - f * noise * (1 - (((Math.sin(i) + 1) * 1e9) % 2)); // noise 185 | 186 | if (j && ++j > pitchJumpTime) { 187 | // pitch jump 188 | frequency += pitchJump; // apply pitch jump 189 | startFrequency += pitchJump; // also apply to start 190 | j = 0; // stop pitch jump time 191 | } 192 | 193 | if (repeatTime && !(++r % repeatTime)) { 194 | // repeat 195 | frequency = startFrequency; // reset frequency 196 | slide = startSlide; // reset slide 197 | j = j || 1; // reset pitch jump time 198 | } 199 | } 200 | 201 | return b; 202 | } 203 | -------------------------------------------------------------------------------- /src/zzfxm.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force 3 | */ 4 | 5 | import { zzfxG, zzfxR } from './zzfx'; 6 | 7 | /** 8 | * @typedef Channel 9 | * @type {Array.} 10 | * @property {Number} 0 - Channel instrument 11 | * @property {Number} 1 - Channel panning (-1 to +1) 12 | * @property {Number} 2 - Note 13 | */ 14 | type Channel = (number | undefined)[]; //[number, number, number]; 15 | 16 | /** 17 | * @typedef Pattern 18 | * @type {Array.} 19 | */ 20 | type Pattern = Channel[]; 21 | 22 | /** 23 | * @typedef Instrument 24 | * @type {Array.} ZzFX sound parameters 25 | */ 26 | type Instrument = (number | undefined)[]; 27 | 28 | /** 29 | * Generate a song 30 | * 31 | * @param instruments - Array of ZzFX sound paramaters. 32 | * @param patterns - Array of pattern data. 33 | * @param sequence - Array of pattern indexes. 34 | * @param [speed=125] - Playback speed of the song (in BPM). 35 | * @returns Left and right channel sample data. 36 | */ 37 | export const zzfxM = (instruments: Instrument[], patterns: Pattern[], sequence: number[], BPM = 125): number[][] => { 38 | let instrumentParameters: Instrument; 39 | let i: number; 40 | let j: number; 41 | let k: number; 42 | let note: number | undefined; 43 | let sample: number; 44 | let patternChannel: Channel; 45 | let notFirstBeat: number | undefined; 46 | let stop: number; 47 | let instrument = 0; 48 | let attenuation = 0; 49 | let outSampleOffset = 0; 50 | let isSequenceEnd: number; 51 | let sampleOffset = 0; 52 | let nextSampleOffset: number; 53 | let sampleBuffer: number[] = []; 54 | const leftChannelBuffer: number[] = []; 55 | const rightChannelBuffer: number[] = []; 56 | let channelIndex = 0; 57 | let panning = 0; 58 | let hasMore = 1; 59 | const sampleCache: Record = {}; 60 | const beatLength = ((zzfxR / BPM) * 60) >> 2; 61 | 62 | // for each channel in order until there are no more 63 | for (; hasMore; channelIndex++) { 64 | // reset current values 65 | sampleBuffer = [(hasMore = notFirstBeat = outSampleOffset = 0)]; 66 | 67 | // for each pattern in sequence 68 | sequence.map((patternIndex, sequenceIndex) => { 69 | // get pattern for current channel, use empty 1 note pattern if none found 70 | patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0]; 71 | 72 | // check if there are more channels 73 | hasMore |= !!patterns[patternIndex][channelIndex] as unknown as number; 74 | 75 | // get next offset, use the length of first channel 76 | nextSampleOffset = 77 | outSampleOffset + (patterns[patternIndex][0].length - 2 - (!notFirstBeat as unknown as number)) * beatLength; 78 | // for each beat in pattern, plus one extra if end of sequence 79 | isSequenceEnd = (sequenceIndex === sequence.length - 1) as unknown as number; 80 | for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) { 81 | // 82 | note = patternChannel[i]; 83 | 84 | // stop if end, different instrument or new note 85 | stop = 86 | (i === patternChannel.length + isSequenceEnd - 1 && isSequenceEnd) || 87 | ((instrument !== (patternChannel[0] || 0)) as unknown as number) | (note as number) | 0; 88 | 89 | // fill buffer with samples for previous beat, most cpu intensive part 90 | for ( 91 | j = 0; 92 | j < beatLength && notFirstBeat; 93 | // fade off attenuation at end of beat if stopping note, prevents clicking 94 | j++ > beatLength - 99 && stop && (attenuation += ((attenuation < 1) as unknown as number) / 99) 95 | ) { 96 | // copy sample to stereo buffers with panning 97 | sample = ((1 - attenuation) * sampleBuffer[sampleOffset++]) / 2 || 0; 98 | leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample; 99 | rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample; 100 | } 101 | 102 | // set up for next note 103 | if (note) { 104 | // set attenuation 105 | attenuation = note % 1; 106 | panning = patternChannel[1] || 0; 107 | if ((note |= 0)) { 108 | // get cached sample 109 | sampleBuffer = sampleCache[`i${(instrument = patternChannel[(sampleOffset = 0)] || 0)}n${note}`] = 110 | sampleCache[`i${instrument}n${note}`] || 111 | // add sample to cache 112 | ((instrumentParameters = [...instruments[instrument]]), 113 | ((instrumentParameters[2] as number) *= 2 ** ((note - 12) / 12)), 114 | // allow negative values to stop notes 115 | note > 0 ? zzfxG(...instrumentParameters) : []); 116 | } 117 | } 118 | } 119 | 120 | // update the sample offset 121 | outSampleOffset = nextSampleOffset; 122 | }); 123 | } 124 | 125 | return [leftChannelBuffer, rightChannelBuffer]; 126 | }; 127 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | #c { 2 | width: 100vw; 3 | outline: 0; 4 | image-rendering: pixelated; /* You definitely want this if you are doing pixel art */ 5 | } 6 | 7 | /* Get rid of any margins, set background black, use flexbox to center */ 8 | html, 9 | body { 10 | margin: 0; 11 | background-color: black; 12 | display: flex; 13 | justify-content: center; 14 | align-items: center; 15 | height: 100%; 16 | } 17 | 18 | /* Input whatever aspect ratio you use here */ 19 | @media (min-aspect-ratio: 16 / 9) { 20 | #c { 21 | height: 100vh; 22 | width: auto; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "esnext", 5 | "lib": ["esnext", "DOM"], 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "resolveJsonModule": true, 9 | "isolatedModules": true, 10 | "esModuleInterop": true, 11 | "noEmit": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "skipLibCheck": true 16 | }, 17 | "include": ["src"] 18 | } 19 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { js13kViteConfig } from 'js13k-vite-plugins'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig(js13kViteConfig()); 5 | --------------------------------------------------------------------------------