├── src ├── style.css ├── vite-env.d.ts └── main.ts ├── package.json ├── .gitignore ├── index.html ├── README.md └── tsconfig.json /src/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "build-a-game-engine-ts", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "typescript": "~5.7.2", 13 | "vite": "^6.2.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Build a Game Engine 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build a Game Engine in TypeScript 2 | 3 | ## TODO 4 | 5 | - [x] Decide what kind of game we're making (top-down tile-based game (like Legends of Zelda: Link to the Past)) 6 | - [x] Set up canvas rendering 7 | - [x] Set up a loop to repaint the canvas 8 | - [x] Decide on the size and render map (camera/viewport) 9 | - [x] Render a character on the map 10 | - [x] Set up event messaging system 11 | - [x] Characters 12 | - [x] Movement 13 | - [ ] Actions (e.g. attack, jump) 14 | - [ ] Enemy AI 15 | - [x] Collision detection 16 | - [ ] Start/restart 17 | - [ ] Death/damage 18 | - [x] Map tiles 19 | - [x] Aspect ratio 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | const FRAMES_PER_SECOND = 1000 / 60; 2 | const TILE_SIZE = 16; 3 | const COLUMNS = 32; 4 | const ROWS = 24; 5 | const MAP_WIDTH = COLUMNS * TILE_SIZE; 6 | const MAP_HEIGHT = ROWS * TILE_SIZE; 7 | 8 | const canvas = document.createElement('canvas'); 9 | canvas.width = MAP_WIDTH; 10 | canvas.height = MAP_HEIGHT; 11 | 12 | const context = canvas.getContext('2d'); 13 | 14 | const app = document.getElementById('app'); 15 | 16 | app?.appendChild(canvas); 17 | 18 | type GameEvent = { 19 | type: 'playerMove'; 20 | direction: 'up' | 'down' | 'left' | 'right'; 21 | }; 22 | 23 | let state: { 24 | previousTime: number; 25 | accumulator: number; 26 | character: [number, number]; 27 | eventQueue: GameEvent[]; 28 | } = { 29 | previousTime: performance.now(), 30 | accumulator: 0, 31 | character: [0, 0], 32 | eventQueue: [], 33 | }; 34 | 35 | type Message = { 36 | type: 'playerQueueEvent'; 37 | event: GameEvent; 38 | }; 39 | 40 | const messagingQueue: Message[] = []; 41 | 42 | window.addEventListener('keydown', (event) => { 43 | switch (event.key) { 44 | case 'ArrowUp': 45 | messagingQueue.push({ 46 | type: 'playerQueueEvent', 47 | event: { type: 'playerMove', direction: 'up' }, 48 | }); 49 | break; 50 | 51 | case 'ArrowDown': 52 | messagingQueue.push({ 53 | type: 'playerQueueEvent', 54 | event: { type: 'playerMove', direction: 'down' }, 55 | }); 56 | break; 57 | case 'ArrowLeft': 58 | messagingQueue.push({ 59 | type: 'playerQueueEvent', 60 | event: { type: 'playerMove', direction: 'left' }, 61 | }); 62 | break; 63 | case 'ArrowRight': 64 | messagingQueue.push({ 65 | type: 'playerQueueEvent', 66 | event: { type: 'playerMove', direction: 'right' }, 67 | }); 68 | break; 69 | } 70 | }); 71 | 72 | // set up the game loop 73 | function render() { 74 | if (!context) { 75 | return; 76 | } 77 | 78 | context.strokeStyle = 'white'; 79 | 80 | requestAnimationFrame((timestamp) => { 81 | // process the messaging queue 82 | const messages = messagingQueue.splice(0, messagingQueue.length); 83 | messages.forEach((msg) => { 84 | switch (msg.type) { 85 | case 'playerQueueEvent': 86 | state.eventQueue.push(msg.event); 87 | break; 88 | } 89 | }); 90 | 91 | const delta = timestamp - state.previousTime; 92 | state.accumulator += delta; 93 | 94 | while (state.accumulator >= FRAMES_PER_SECOND) { 95 | // handle updates 96 | const events = state.eventQueue.splice(0, state.eventQueue.length); 97 | events.forEach((event) => { 98 | switch (event.type) { 99 | case 'playerMove': 100 | let newX = state.character[0]; 101 | let newY = state.character[1]; 102 | 103 | if (event.direction === 'up') { 104 | newY = Math.max(0, state.character[1] - 1); 105 | } else if (event.direction === 'down') { 106 | newY = Math.min(ROWS - 1, state.character[1] + 1); 107 | } 108 | 109 | if (event.direction === 'left') { 110 | newX = Math.max(0, state.character[0] - 1); 111 | } else if (event.direction === 'right') { 112 | newX = Math.min(COLUMNS - 1, state.character[0] + 1); 113 | } 114 | 115 | state.character = [newX, newY]; 116 | break; 117 | } 118 | }); 119 | 120 | // render the map / characters 121 | for (let tileColNum = 0; tileColNum < COLUMNS; tileColNum++) { 122 | for (let tileRowNum = 0; tileRowNum < ROWS; tileRowNum++) { 123 | context.fillStyle = '#f5e1e2'; 124 | 125 | context.fillRect( 126 | tileColNum * TILE_SIZE, 127 | tileRowNum * TILE_SIZE, 128 | TILE_SIZE, 129 | TILE_SIZE, 130 | ); 131 | context.strokeRect( 132 | tileColNum * TILE_SIZE, 133 | tileRowNum * TILE_SIZE, 134 | TILE_SIZE, 135 | TILE_SIZE, 136 | ); 137 | 138 | if ( 139 | state.character[0] === tileColNum && 140 | state.character[1] === tileRowNum 141 | ) { 142 | context.fillStyle = '#bd1434'; 143 | context.fillRect( 144 | tileColNum * TILE_SIZE, 145 | tileRowNum * TILE_SIZE, 146 | TILE_SIZE, 147 | TILE_SIZE, 148 | ); 149 | } 150 | } 151 | } 152 | 153 | state.accumulator -= FRAMES_PER_SECOND; 154 | } 155 | 156 | state.previousTime = timestamp; 157 | 158 | render(); 159 | }); 160 | } 161 | 162 | render(); 163 | --------------------------------------------------------------------------------