├── 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 |
--------------------------------------------------------------------------------