@muhammedaksam/opentui-doom 0.2.1 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/doom-input.ts CHANGED
@@ -56,70 +56,74 @@ export const DoomKeys = {
56
56
  const keyStates = new Map<string, boolean>();
57
57
 
58
58
  /**
59
- * Map an OpenTUI key event to a DOOM key code
59
+ * Map an OpenTUI key event to DOOM key code(s)
60
+ * Returns an array of key codes - for most keys this is a single code,
61
+ * but for WASD we return both the movement key AND the character
62
+ * so both gameplay movement and text input work.
60
63
  */
61
- function mapKeyToDoom(key: KeyEvent): number | null {
64
+ function mapKeyToDoom(key: KeyEvent): number[] {
62
65
  const name = key.name?.toLowerCase() ?? "";
63
66
 
64
67
  // Arrow keys
65
- if (name === "up" || key.sequence === "\x1b[A") return DoomKeys.KEY_UPARROW;
66
- if (name === "down" || key.sequence === "\x1b[B") return DoomKeys.KEY_DOWNARROW;
67
- if (name === "left" || key.sequence === "\x1b[D") return DoomKeys.KEY_LEFTARROW;
68
- if (name === "right" || key.sequence === "\x1b[C") return DoomKeys.KEY_RIGHTARROW;
69
-
70
- // WASD movement (alternative to arrows)
71
- if (name === "w") return DoomKeys.KEY_UPARROW;
72
- if (name === "s") return DoomKeys.KEY_DOWNARROW;
73
- if (name === "a") return DoomKeys.KEY_STRAFE_L;
74
- if (name === "d") return DoomKeys.KEY_STRAFE_R;
68
+ if (name === "up" || key.sequence === "\x1b[A") return [DoomKeys.KEY_UPARROW];
69
+ if (name === "down" || key.sequence === "\x1b[B") return [DoomKeys.KEY_DOWNARROW];
70
+ if (name === "left" || key.sequence === "\x1b[D") return [DoomKeys.KEY_LEFTARROW];
71
+ if (name === "right" || key.sequence === "\x1b[C") return [DoomKeys.KEY_RIGHTARROW];
72
+
73
+ // WASD movement - send BOTH movement key AND character
74
+ // Movement key ensures gameplay works, character ensures text input works
75
+ if (name === "w") return [DoomKeys.KEY_UPARROW, "w".charCodeAt(0)];
76
+ if (name === "s") return [DoomKeys.KEY_DOWNARROW, "s".charCodeAt(0)];
77
+ if (name === "a") return [DoomKeys.KEY_STRAFE_L, "a".charCodeAt(0)];
78
+ if (name === "d") return [DoomKeys.KEY_STRAFE_R, "d".charCodeAt(0)];
75
79
 
76
80
  // Action keys
77
- if (name === "space") return " ".charCodeAt(0); // Use
78
- if (name === "return" || name === "enter") return DoomKeys.KEY_ENTER;
79
- if (name === "escape") return DoomKeys.KEY_ESCAPE;
80
- if (name === "tab") return DoomKeys.KEY_TAB;
81
- if (name === "backspace") return DoomKeys.KEY_BACKSPACE;
81
+ if (name === "space") return [" ".charCodeAt(0)]; // Use
82
+ if (name === "return" || name === "enter") return [DoomKeys.KEY_ENTER];
83
+ if (name === "escape") return [DoomKeys.KEY_ESCAPE];
84
+ if (name === "tab") return [DoomKeys.KEY_TAB];
85
+ if (name === "backspace") return [DoomKeys.KEY_BACKSPACE];
82
86
 
83
87
  // Fire (Ctrl) - but not Ctrl+C which should exit
84
- if (key.ctrl && key.name !== "c") return DoomKeys.KEY_FIRE;
88
+ if (key.ctrl && key.name !== "c") return [DoomKeys.KEY_FIRE];
85
89
 
86
90
  // Alt for strafe
87
- if (key.meta || key.name === "alt") return DoomKeys.KEY_LALT;
91
+ if (key.meta || key.name === "alt") return [DoomKeys.KEY_LALT];
88
92
 
89
93
  // Shift for run
90
- if (key.shift) return DoomKeys.KEY_RSHIFT;
94
+ if (key.shift) return [DoomKeys.KEY_RSHIFT];
91
95
 
92
96
  // Function keys
93
- if (name === "f1") return DoomKeys.KEY_F1;
94
- if (name === "f2") return DoomKeys.KEY_F2;
95
- if (name === "f3") return DoomKeys.KEY_F3;
96
- if (name === "f4") return DoomKeys.KEY_F4;
97
- if (name === "f5") return DoomKeys.KEY_F5;
98
- if (name === "f6") return DoomKeys.KEY_F6;
99
- if (name === "f7") return DoomKeys.KEY_F7;
100
- if (name === "f8") return DoomKeys.KEY_F8;
101
- if (name === "f9") return DoomKeys.KEY_F9;
102
- if (name === "f10") return DoomKeys.KEY_F10;
103
- if (name === "f11") return DoomKeys.KEY_F11;
104
- if (name === "f12") return DoomKeys.KEY_F12;
97
+ if (name === "f1") return [DoomKeys.KEY_F1];
98
+ if (name === "f2") return [DoomKeys.KEY_F2];
99
+ if (name === "f3") return [DoomKeys.KEY_F3];
100
+ if (name === "f4") return [DoomKeys.KEY_F4];
101
+ if (name === "f5") return [DoomKeys.KEY_F5];
102
+ if (name === "f6") return [DoomKeys.KEY_F6];
103
+ if (name === "f7") return [DoomKeys.KEY_F7];
104
+ if (name === "f8") return [DoomKeys.KEY_F8];
105
+ if (name === "f9") return [DoomKeys.KEY_F9];
106
+ if (name === "f10") return [DoomKeys.KEY_F10];
107
+ if (name === "f11") return [DoomKeys.KEY_F11];
108
+ if (name === "f12") return [DoomKeys.KEY_F12];
105
109
 
106
110
  // Weapon selection (1-9, 0)
107
- if (name >= "0" && name <= "9") return name.charCodeAt(0);
111
+ if (name >= "0" && name <= "9") return [name.charCodeAt(0)];
108
112
 
109
113
  // Plus/minus for gamma/zoom
110
- if (name === "+" || name === "=") return DoomKeys.KEY_EQUALS;
111
- if (name === "-") return DoomKeys.KEY_MINUS;
114
+ if (name === "+" || name === "=") return [DoomKeys.KEY_EQUALS];
115
+ if (name === "-") return [DoomKeys.KEY_MINUS];
112
116
 
113
117
  // Y/N for prompts
114
- if (name === "y") return "y".charCodeAt(0);
115
- if (name === "n") return "n".charCodeAt(0);
118
+ if (name === "y") return ["y".charCodeAt(0)];
119
+ if (name === "n") return ["n".charCodeAt(0)];
116
120
 
117
121
  // Other letter keys (for cheats, etc)
118
122
  if (name.length === 1 && name >= "a" && name <= "z") {
119
- return name.charCodeAt(0);
123
+ return [name.charCodeAt(0)];
120
124
  }
121
125
 
122
- return null;
126
+ return [];
123
127
  }
124
128
 
125
129
  /**
@@ -145,12 +149,17 @@ export function createDoomInputHandler(options: DoomInputOptions) {
145
149
  return;
146
150
  }
147
151
 
148
- const doomKey = mapKeyToDoom(key);
152
+ const doomKeys = mapKeyToDoom(key);
149
153
 
150
- if (doomKey === null) return;
154
+ if (doomKeys.length === 0) return;
151
155
 
152
156
  const keyId = key.name || key.sequence || "";
153
157
  const wasPressed = keyStates.get(keyId) ?? false;
158
+ const keyName = key.name?.toLowerCase() ?? "";
159
+
160
+ // Menu confirmation keys (y/n) should always send keydown on every press
161
+ // These are used for quit dialogs and other prompts
162
+ const isMenuConfirmKey = keyName === "y" || keyName === "n";
154
163
 
155
164
  // Clear any existing release timer for this key
156
165
  const existingTimer = keyTimers.get(keyId);
@@ -159,17 +168,34 @@ export function createDoomInputHandler(options: DoomInputOptions) {
159
168
  keyTimers.delete(keyId);
160
169
  }
161
170
 
162
- // Key press - only send if not already pressed
163
- if (!wasPressed) {
171
+ // Key press - send if not already pressed, OR if it's a menu confirmation key
172
+ if (!wasPressed || isMenuConfirmKey) {
164
173
  keyStates.set(keyId, true);
165
- engine.pushKey(true, doomKey);
174
+ // Send all mapped keys (for WASD this includes both movement and character)
175
+ for (const doomKey of doomKeys) {
176
+ engine.pushKey(true, doomKey);
177
+ }
178
+
179
+ // For menu confirmation keys, immediately send release too
180
+ // since DOOM only cares about the keydown event
181
+ if (isMenuConfirmKey) {
182
+ setTimeout(() => {
183
+ for (const doomKey of doomKeys) {
184
+ engine.pushKey(false, doomKey);
185
+ }
186
+ keyStates.set(keyId, false);
187
+ }, 50);
188
+ return;
189
+ }
166
190
  }
167
191
 
168
- // Schedule key release after 300ms of no input
192
+ // Schedule key release after 300ms of no input (for non-menu keys)
169
193
  const timer = setTimeout(() => {
170
194
  if (keyStates.get(keyId)) {
171
195
  keyStates.set(keyId, false);
172
- engine.pushKey(false, doomKey);
196
+ for (const doomKey of doomKeys) {
197
+ engine.pushKey(false, doomKey);
198
+ }
173
199
  keyTimers.delete(keyId);
174
200
  }
175
201
  }, 300);
@@ -0,0 +1,129 @@
1
+ /**
2
+ * DOOM Mouse Input Handler
3
+ *
4
+ * Provides mouse-based turning and firing for DOOM.
5
+ * Mouse horizontal movement translates to left/right turning.
6
+ * Left click fires the weapon.
7
+ */
8
+
9
+ import type { DoomEngine } from "./doom-engine";
10
+ import { DoomKeys } from "./doom-input";
11
+ import { debugLog } from "./debug";
12
+
13
+ export interface DoomMouseOptions {
14
+ engine: DoomEngine;
15
+ sensitivity?: number; // Cells of movement before triggering turn (default: 2)
16
+ }
17
+
18
+ export interface DoomMouseHandler {
19
+ onMouseMove: (x: number, y: number) => void;
20
+ onMouseDown: (button: number) => void;
21
+ onMouseUp: (button: number) => void;
22
+ reset: () => void;
23
+ }
24
+
25
+ /**
26
+ * Create a mouse handler that forwards mouse events to DOOM
27
+ */
28
+ export function createDoomMouseHandler(options: DoomMouseOptions): DoomMouseHandler {
29
+ const { engine } = options;
30
+
31
+ let lastMouseX: number | null = null;
32
+ let isLeftMouseDown = false;
33
+ let currentTurnKey: number | null = null;
34
+ let releaseTimer: ReturnType<typeof setTimeout> | null = null;
35
+
36
+ const RELEASE_DELAY = 100; // Release key 100ms after last movement
37
+
38
+ return {
39
+ /**
40
+ * Handle mouse movement - hold turn key while moving
41
+ */
42
+ onMouseMove(x: number, _y: number): void {
43
+ if (lastMouseX === null) {
44
+ lastMouseX = x;
45
+ return;
46
+ }
47
+
48
+ const delta = x - lastMouseX;
49
+ lastMouseX = x;
50
+
51
+ if (delta === 0) return;
52
+
53
+ const newTurnKey = delta > 0 ? DoomKeys.KEY_RIGHTARROW : DoomKeys.KEY_LEFTARROW;
54
+
55
+ // Clear any pending release
56
+ if (releaseTimer) {
57
+ clearTimeout(releaseTimer);
58
+ releaseTimer = null;
59
+ }
60
+
61
+ // If direction changed, release old and press new
62
+ if (currentTurnKey !== newTurnKey) {
63
+ if (currentTurnKey !== null) {
64
+ engine.pushKey(false, currentTurnKey);
65
+ }
66
+ engine.pushKey(true, newTurnKey);
67
+ currentTurnKey = newTurnKey;
68
+ }
69
+
70
+ // Schedule release after delay (will be cancelled if more movement comes)
71
+ releaseTimer = setTimeout(() => {
72
+ if (currentTurnKey !== null) {
73
+ engine.pushKey(false, currentTurnKey);
74
+ currentTurnKey = null;
75
+ }
76
+ releaseTimer = null;
77
+ }, RELEASE_DELAY);
78
+ },
79
+
80
+ /**
81
+ * Handle mouse button press
82
+ */
83
+ onMouseDown(button: number): void {
84
+ // Left click (button 0) = fire
85
+ if (button === 0 && !isLeftMouseDown) {
86
+ isLeftMouseDown = true;
87
+ engine.pushKey(true, DoomKeys.KEY_FIRE);
88
+ debugLog("Mouse", "Fire pressed");
89
+ }
90
+ },
91
+
92
+ /**
93
+ * Handle mouse button release
94
+ */
95
+ onMouseUp(button: number): void {
96
+ // Left click release = stop firing
97
+ if (button === 0 && isLeftMouseDown) {
98
+ isLeftMouseDown = false;
99
+ engine.pushKey(false, DoomKeys.KEY_FIRE);
100
+ debugLog("Mouse", "Fire released");
101
+ }
102
+ },
103
+
104
+ /**
105
+ * Reset mouse state (useful when window loses focus)
106
+ */
107
+ reset(): void {
108
+ lastMouseX = null;
109
+
110
+ // Clear pending release timer
111
+ if (releaseTimer) {
112
+ clearTimeout(releaseTimer);
113
+ releaseTimer = null;
114
+ }
115
+
116
+ // Release any held turn key
117
+ if (currentTurnKey !== null) {
118
+ engine.pushKey(false, currentTurnKey);
119
+ currentTurnKey = null;
120
+ }
121
+
122
+ // Release fire if held
123
+ if (isLeftMouseDown) {
124
+ isLeftMouseDown = false;
125
+ engine.pushKey(false, DoomKeys.KEY_FIRE);
126
+ }
127
+ }
128
+ };
129
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Save Game Manager for OpenTUI-DOOM
3
+ *
4
+ * Handles persistence of DOOM save games to ~/.opentui-doom/
5
+ * DOOM uses 6 save slots (0-5) with files named doomsav{N}.dsg
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "fs";
9
+ import { join } from "path";
10
+ import { homedir } from "os";
11
+ import { debugLog } from "./debug";
12
+
13
+ // Save game directory path
14
+ const SAVE_DIR = join(homedir(), ".opentui-doom");
15
+
16
+ // DOOM save file format: doomsav{0-5}.dsg
17
+ const SAVE_FILE_PATTERN = /^doomsav([0-5])\.dsg$/;
18
+
19
+ /**
20
+ * Ensure the save directory exists
21
+ */
22
+ export function ensureSaveDir(): void {
23
+ if (!existsSync(SAVE_DIR)) {
24
+ mkdirSync(SAVE_DIR, { recursive: true });
25
+ debugLog("Saves", `Created save directory: ${SAVE_DIR}`);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Get the save game directory path
31
+ */
32
+ export function getSaveGameDir(): string {
33
+ ensureSaveDir();
34
+ return SAVE_DIR;
35
+ }
36
+
37
+ /**
38
+ * Get the path to a save file for a given slot (0-5)
39
+ */
40
+ export function getSaveFilePath(slot: number): string {
41
+ if (slot < 0 || slot > 5) {
42
+ throw new Error(`Invalid save slot: ${slot}. Must be 0-5.`);
43
+ }
44
+ return join(SAVE_DIR, `doomsav${slot}.dsg`);
45
+ }
46
+
47
+ /**
48
+ * Load all existing save games from disk
49
+ * Returns a Map of slot number to file contents (Uint8Array)
50
+ */
51
+ export function loadExistingSaves(): Map<number, Uint8Array> {
52
+ ensureSaveDir();
53
+ const saves = new Map<number, Uint8Array>();
54
+
55
+ try {
56
+ const files = readdirSync(SAVE_DIR);
57
+ for (const file of files) {
58
+ const match = file.match(SAVE_FILE_PATTERN);
59
+ if (match && match[1]) {
60
+ const slot = parseInt(match[1], 10);
61
+ const filePath = join(SAVE_DIR, file);
62
+ try {
63
+ const data = readFileSync(filePath);
64
+ saves.set(slot, new Uint8Array(data));
65
+ debugLog("Saves", `Loaded save slot ${slot}: ${data.length} bytes`);
66
+ } catch (e) {
67
+ debugLog("Saves", `Failed to read save file ${filePath}: ${e}`);
68
+ }
69
+ }
70
+ }
71
+ } catch (e) {
72
+ debugLog("Saves", `Failed to list save directory: ${e}`);
73
+ }
74
+
75
+ debugLog("Saves", `Loaded ${saves.size} existing saves`);
76
+ return saves;
77
+ }
78
+
79
+ /**
80
+ * Write a save game to disk
81
+ */
82
+ export function writeSave(slot: number, data: Uint8Array): boolean {
83
+ ensureSaveDir();
84
+ const filePath = getSaveFilePath(slot);
85
+
86
+ try {
87
+ writeFileSync(filePath, data);
88
+ debugLog("Saves", `Wrote save slot ${slot}: ${data.length} bytes to ${filePath}`);
89
+ return true;
90
+ } catch (e) {
91
+ debugLog("Saves", `Failed to write save slot ${slot}: ${e}`);
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Check if a save exists for a given slot
98
+ */
99
+ export function saveExists(slot: number): boolean {
100
+ const filePath = getSaveFilePath(slot);
101
+ return existsSync(filePath);
102
+ }
103
+
104
+ /**
105
+ * Read a save game from disk
106
+ */
107
+ export function readSave(slot: number): Uint8Array | null {
108
+ const filePath = getSaveFilePath(slot);
109
+
110
+ if (!existsSync(filePath)) {
111
+ return null;
112
+ }
113
+
114
+ try {
115
+ const data = readFileSync(filePath);
116
+ return new Uint8Array(data);
117
+ } catch (e) {
118
+ debugLog("Saves", `Failed to read save slot ${slot}: ${e}`);
119
+ return null;
120
+ }
121
+ }
package/src/index.ts CHANGED
@@ -17,7 +17,9 @@ import {
17
17
  } from "@opentui/core";
18
18
  import { DoomEngine, DOOM_WIDTH, DOOM_HEIGHT } from "./doom-engine";
19
19
  import { createDoomInputHandler, getControlsHelp } from "./doom-input";
20
+ import { createDoomMouseHandler, type DoomMouseHandler } from "./doom-mouse";
20
21
  import { shutdownAudio } from "./doom-audio";
22
+ import { debugLog } from "./debug";
21
23
  import { parseArgs } from "util";
22
24
 
23
25
  // Parse command line arguments
@@ -34,6 +36,11 @@ const { values } = parseArgs({
34
36
  short: "h",
35
37
  default: false,
36
38
  },
39
+ mouse: {
40
+ type: "boolean",
41
+ short: "m",
42
+ default: true,
43
+ },
37
44
  },
38
45
  });
39
46
 
@@ -47,7 +54,7 @@ Options:
47
54
  -w, --wad Path to DOOM WAD file (default: doom1.wad)
48
55
  -h, --help Show this help message
49
56
 
50
- ${getControlsHelp()}
57
+ ${getControlsHelp()}${values.mouse ? '\n Mouse=Aim/Fire' : ''}
51
58
  `);
52
59
  process.exit(0);
53
60
  }
@@ -60,12 +67,42 @@ const renderer = await createCliRenderer({
60
67
 
61
68
  // Handle graceful shutdown
62
69
  const cleanup = (signal?: string) => {
70
+ debugLog('Exit', `cleanup called with signal: ${signal}`);
71
+
72
+ // Set flag to stop the game loop FIRST - this is critical
73
+ isExiting = true;
74
+ debugLog('Exit', 'isExiting set to true');
75
+
76
+ // Sync saves before exiting
77
+ if (doomEngine) {
78
+ try {
79
+ doomEngine.syncSaves();
80
+ debugLog('Exit', 'saves synced to disk');
81
+ } catch (e) {
82
+ debugLog('Exit', `failed to sync saves: ${e}`);
83
+ }
84
+ }
85
+
86
+ // Clear the frame callback to stop DOOM from ticking
87
+ try {
88
+ renderer.setFrameCallback(null as any);
89
+ debugLog('Exit', 'frame callback cleared');
90
+ } catch (e) {
91
+ debugLog('Exit', `failed to clear frame callback: ${e}`);
92
+ }
93
+
63
94
  shutdownAudio();
95
+ debugLog('Exit', 'shutdownAudio completed');
96
+
64
97
  try {
65
98
  renderer.stop();
99
+ debugLog('Exit', 'renderer.stop completed');
66
100
  } catch (e) {
67
- // Ignore error if renderer already stopped
101
+ debugLog('Exit', `renderer.stop error: ${e}`);
68
102
  }
103
+
104
+ // Exit the process
105
+ debugLog('Exit', 'calling process.exit(0)');
69
106
  process.exit(0);
70
107
  };
71
108
 
@@ -96,12 +133,19 @@ container.add(loadingText);
96
133
  // Try to initialize DOOM engine
97
134
  let doomEngine: DoomEngine | null = null;
98
135
  let framebufferRenderable: FrameBufferRenderable | null = null;
136
+ let isExiting = false; // Flag to stop the game loop when exiting
137
+ let lastSaveSyncTime = 0; // Track when we last synced saves
138
+ const SAVE_SYNC_INTERVAL = 5000; // Sync saves every 5 seconds
139
+ let mouseHandler: DoomMouseHandler | null = null;
99
140
 
100
141
  async function initDoom() {
101
142
  try {
102
143
  loadingText.content = `Loading DOOM from: ${values.wad}`;
103
144
 
104
- doomEngine = new DoomEngine(values.wad!);
145
+ doomEngine = new DoomEngine({
146
+ wadPath: values.wad!,
147
+ onQuit: cleanup,
148
+ });
105
149
  await doomEngine.init();
106
150
 
107
151
  // Remove loading text
@@ -120,9 +164,12 @@ async function initDoom() {
120
164
  renderer.root.add(framebufferRenderable);
121
165
 
122
166
  // Add controls overlay
167
+ const controlsContent = values.mouse
168
+ ? "DOOM | Ctrl+C to exit | WASD=Move Mouse=Aim Click=Fire"
169
+ : "DOOM | Ctrl+C to exit | Arrow/WASD=Move Space=Use Ctrl=Fire";
123
170
  const controlsText = new TextRenderable(renderer, {
124
171
  id: "controls",
125
- content: "DOOM | Ctrl+C to exit | Arrow/WASD=Move Space=Use Ctrl=Fire",
172
+ content: controlsContent,
126
173
  position: "absolute",
127
174
  left: 1,
128
175
  top: 0,
@@ -139,6 +186,30 @@ async function initDoom() {
139
186
  });
140
187
  renderer.keyInput.on("keypress", inputHandler);
141
188
 
189
+ // Set up mouse handler if enabled
190
+ if (values.mouse) {
191
+ mouseHandler = createDoomMouseHandler({
192
+ engine: doomEngine,
193
+ sensitivity: 2, // Adjust for terminal cell size
194
+ });
195
+
196
+ // Attach mouse events to framebuffer
197
+ framebufferRenderable.onMouseMove = (event) => {
198
+ mouseHandler?.onMouseMove(event.x, event.y);
199
+ };
200
+ framebufferRenderable.onMouseDrag = (event) => {
201
+ mouseHandler?.onMouseMove(event.x, event.y);
202
+ };
203
+ framebufferRenderable.onMouseDown = (event) => {
204
+ mouseHandler?.onMouseDown(event.button);
205
+ };
206
+ framebufferRenderable.onMouseUp = (event) => {
207
+ mouseHandler?.onMouseUp(event.button);
208
+ };
209
+
210
+ debugLog("Input", "Mouse look enabled");
211
+ }
212
+
142
213
  // Start game loop
143
214
  renderer.setFrameCallback(gameLoop);
144
215
 
@@ -172,10 +243,20 @@ async function initDoom() {
172
243
  }
173
244
 
174
245
  async function gameLoop(deltaMs: number) {
246
+ // Bail out immediately if we're exiting
247
+ if (isExiting) return;
248
+
175
249
  if (!doomEngine || !framebufferRenderable) return;
176
250
 
177
251
  // Run DOOM tick
178
252
  doomEngine.tick();
253
+
254
+ // Periodic save sync (every 5 seconds)
255
+ const now = Date.now();
256
+ if (now - lastSaveSyncTime > SAVE_SYNC_INTERVAL) {
257
+ doomEngine.syncSaves();
258
+ lastSaveSyncTime = now;
259
+ }
179
260
 
180
261
  // Get framebuffer from DOOM
181
262
  const pixels = doomEngine.getFrameBuffer();