diff --git a/public/ioquake3/player.html b/public/ioquake3/player.html
index afbd073..00dcfc4 100644
--- a/public/ioquake3/player.html
+++ b/public/ioquake3/player.html
@@ -287,6 +287,7 @@
}
const archives = [...stored.archives];
if (launch.packagePk3) archives.push({ name: `${launch.mapName}.pk3`, data: launch.packagePk3 });
+ if (launch.previewPk3) archives.push({ name: 'zzzz-q3edit-preview.pk3', data: launch.previewPk3 });
return { archives, gameDir: configuredGameDir };
}
@@ -302,13 +303,18 @@
? stored.archives.filter((archive) => /^pak[0-9]+\.pk3$/i.test(safeFileName(archive.name)))
: stored.archives;
if (launch.packagePk3) archives.push({ name: `${launch.mapName}.pk3`, data: launch.packagePk3 });
+ if (launch.previewPk3) archives.push({ name: 'zzzz-q3edit-preview.pk3', data: launch.previewPk3 });
return { archives, gameDir: 'baseq3' };
}
if (stored.openArenaEnabled) {
const archives = await loadOpenArenaArchives();
- if (launch.packagePk3) archives.push({ name: `${launch.mapName}.pk3`, data: launch.packagePk3 });
- else archives.push(...stored.archives);
+ if (launch.packagePk3) {
+ archives.push({ name: `${launch.mapName}.pk3`, data: launch.packagePk3 });
+ } else {
+ archives.push(...stored.archives);
+ if (launch.previewPk3) archives.push({ name: 'zzzz-q3edit-preview.pk3', data: launch.previewPk3 });
+ }
return { archives, gameDir: 'baseoa' };
}
@@ -329,12 +335,12 @@
setStatus(`Installing ${name} (${index + 1} of ${archives.length})…`);
module.FS.writeFile(`${root}/${name}`, new Uint8Array(archive.data));
}
- // Package preview must resolve the BSP and AAS from the produced PK3.
- // Writing loose copies here would hide missing package entries.
- if (!launch.packagePk3) {
- module.FS.writeFile(`${root}/maps/${launch.mapName}.bsp`, new Uint8Array(launch.bsp));
+ // Package and direct Quick Play previews resolve the BSP from their
+ // generated PK3, keeping lookup priority consistent across games.
+ if (!launch.packagePk3 && !launch.previewPk3) {
+ module.FS.writeFile(`${root}/maps/${launch.runtimeMapName}.bsp`, new Uint8Array(launch.bsp));
if (launch.aas) {
- module.FS.writeFile(`${root}/maps/${launch.mapName}.aas`, new Uint8Array(launch.aas));
+ module.FS.writeFile(`${root}/maps/${launch.runtimeMapName}.aas`, new Uint8Array(launch.aas));
}
}
}
@@ -387,7 +393,7 @@
'+set', 'bot_minplayers', String(launch.botCount + 1),
] : []),
'+set', 'com_basegame', assets.gameDir,
- '+devmap', launch.mapName,
+ '+devmap', launch.runtimeMapName,
...((launch.botCount > 0 || launch.noclip || (launch.commands || []).length > 0) ? ['+wait', '30'] : []),
...botArguments,
...(launch.noclip ? ['+noclip'] : []),
@@ -451,6 +457,7 @@
if (event.data?.type !== 'q3edit-player:launch' || started) return;
started = true;
const mapName = String(event.data.mapName || 'compile').replace(/[^a-zA-Z0-9_-]/g, '') || 'compile';
+ const runtimeMapName = String(event.data.runtimeMapName || mapName).replace(/[^a-zA-Z0-9_-]/g, '') || mapName;
const bsp = event.data.bsp;
if (!(bsp instanceof ArrayBuffer)) {
fail(new Error('The compiled BSP data was not provided'));
@@ -466,10 +473,11 @@
Array.isArray(command.args) && command.args.every(argument => typeof argument === 'string')
) : [];
const packagePk3 = event.data.packagePk3 instanceof ArrayBuffer ? event.data.packagePk3 : null;
+ const previewPk3 = event.data.previewPk3 instanceof ArrayBuffer ? event.data.previewPk3 : null;
// Reading the stored project is also a compatibility fallback for an
// editor tab that was already open when the player shell was updated.
const gameDirectory = safeGameDirectory(event.data.gameDirectory ?? loadProjectGameDirectory());
- void start({ mapName, gameDirectory, bsp, aas, packagePk3, botCount, botSkill, noclip: event.data.noclip === true, commands });
+ void start({ mapName, runtimeMapName, gameDirectory, bsp, aas, packagePk3, previewPk3, botCount, botSkill, noclip: event.data.noclip === true, commands });
});
window.addEventListener('error', (event) => fail(event.error ?? event.message));
diff --git a/src/game-preview.ts b/src/game-preview.ts
new file mode 100644
index 0000000..8c9301d
--- /dev/null
+++ b/src/game-preview.ts
@@ -0,0 +1,26 @@
+import { zipSync } from 'fflate';
+
+/**
+ * Give loose Quick Play builds a content-addressed name so an older BSP with
+ * the document's filename in an enabled PK3 cannot win Quake's search lookup.
+ */
+export function quickPlayRuntimeMapName(mapName: string, bsp: Uint8Array): string {
+ let hash = 2166136261;
+ for (const byte of bsp) hash = Math.imul(hash ^ byte, 16777619);
+
+ const safeBase = mapName.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 38) || 'compile';
+ const fingerprint = (hash >>> 0).toString(16).padStart(8, '0');
+ return `${safeBase}_q3e_${fingerprint}`;
+}
+
+/** Package the current compile as the highest-priority archive in the preview filesystem. */
+export function createQuickPlayPk3(
+ runtimeMapName: string,
+ bsp: Uint8Array,
+ aas: Uint8Array | null,
+): Uint8Array {
+ return zipSync({
+ [`maps/${runtimeMapName}.bsp`]: bsp,
+ ...(aas ? { [`maps/${runtimeMapName}.aas`]: aas } : {}),
+ }, { level: 0 });
+}
diff --git a/src/release-notes/2026-08-05-game-export-preview.md b/src/release-notes/2026-08-05-game-export-preview.md
new file mode 100644
index 0000000..bb74553
--- /dev/null
+++ b/src/release-notes/2026-08-05-game-export-preview.md
@@ -0,0 +1,18 @@
+---
+id: 2026-08-05-game-export-preview
+title: August 5, 2026 — Game Export and Preview
+date: 2026-08-05
+order: 2
+---
+
+Packaging and testing maps now fit more naturally into both Maker projects and the editor's Quick Play workflow.
+
+## Maker export target
+
+- Release Package can export a game-level bundle for Maker projects.
+- The export includes the compiled level and the supporting files needed by the game project.
+
+## Reliable Quick Play updates
+
+- Unsaved previews use a content-addressed runtime map name, preventing an older BSP in an enabled archive from taking precedence.
+- Preview files are delivered as a temporary PK3 and the player page is cache-busted for each launch.
diff --git a/src/ui.ts b/src/ui.ts
index cdce96e..20aedcb 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -58,6 +58,7 @@ import { MapOrganizationController, type NavigationState } from './map-organizat
import { openMapOrganizationDialog } from './map-organization-dialog';
import { SurfaceInspector } from './surface-inspector';
import { validateProjectShaderFiles } from './q3-shader-source';
+import { createQuickPlayPk3, quickPlayRuntimeMapName } from './game-preview';
export interface AssetLoadingHandle {
ready: Promise;
@@ -2311,6 +2312,12 @@ export class UI {
this.editor.runtimeEntityMessages = [];
const safeMapName = mapName.replace(/[^a-zA-Z0-9_-]/g, '') || 'compile';
+ const runtimeMapName = packagePk3
+ ? safeMapName
+ : quickPlayRuntimeMapName(safeMapName, bsp);
+ const previewPk3 = packagePk3
+ ? null
+ : createQuickPlayPk3(runtimeMapName, bsp, aas);
const bspCopy = new Uint8Array(bsp.byteLength);
bspCopy.set(bsp);
const retainedBsp = new Uint8Array(bsp.byteLength);
@@ -2372,7 +2379,7 @@ export class UI {
const frame = document.createElement('iframe');
frame.className = 'game-preview-frame';
frame.title = `ioquake3 preview of ${safeMapName}`;
- frame.src = '/ioquake3/player.html';
+ frame.src = `/ioquake3/player.html?v=${Date.now()}`;
frame.allow = 'autoplay; fullscreen';
const actions = document.createElement('div');
@@ -2403,6 +2410,7 @@ export class UI {
const launchMessage = {
type: 'q3edit-player:launch',
mapName: safeMapName,
+ runtimeMapName,
gameDirectory: this.gamePreviewLaunch?.gameDirectory ?? 'baseq3',
bsp: bspCopy.buffer,
aas: aasCopy?.buffer ?? null,
@@ -2411,11 +2419,13 @@ export class UI {
noclip,
commands,
packagePk3: packageCopy?.buffer ?? null,
+ previewPk3: previewPk3?.buffer ?? null,
};
const transfer = [
bspCopy.buffer,
...(aasCopy ? [aasCopy.buffer] : []),
...(packageCopy ? [packageCopy.buffer] : []),
+ ...(previewPk3 ? [previewPk3.buffer] : []),
];
frame.contentWindow?.postMessage(launchMessage, window.location.origin, transfer);
} else if (message?.type === 'q3edit-player:status') {
diff --git a/tests/game-preview.test.ts b/tests/game-preview.test.ts
new file mode 100644
index 0000000..3bcfaf2
--- /dev/null
+++ b/tests/game-preview.test.ts
@@ -0,0 +1,32 @@
+import { unzipSync } from 'fflate';
+import { describe, expect, it } from 'vitest';
+import { createQuickPlayPk3, quickPlayRuntimeMapName } from '../src/game-preview';
+
+describe('Quick Play runtime map names', () => {
+ it('uses BSP contents to avoid collisions with maps in enabled PK3 files', () => {
+ const first = quickPlayRuntimeMapName('test-map', new Uint8Array([1, 2, 3]));
+ const same = quickPlayRuntimeMapName('test-map', new Uint8Array([1, 2, 3]));
+ const rebuilt = quickPlayRuntimeMapName('test-map', new Uint8Array([1, 2, 4]));
+
+ expect(first).toBe(same);
+ expect(first).toMatch(/^test-map_q3e_[0-9a-f]{8}$/);
+ expect(rebuilt).not.toBe(first);
+ });
+
+ it('produces a valid Quake path component within MAX_QPATH', () => {
+ const name = quickPlayRuntimeMapName('a very/long map name!'.repeat(8), new Uint8Array([9]));
+
+ expect(name).toMatch(/^[a-zA-Z0-9_-]+$/);
+ expect(`maps/${name}.bsp`.length).toBeLessThan(64);
+ });
+
+ it('packages the current BSP and AAS under the runtime name', () => {
+ const bsp = new Uint8Array([1, 2, 3]);
+ const aas = new Uint8Array([4, 5]);
+ const files = unzipSync(createQuickPlayPk3('test-map_q3e_12345678', bsp, aas));
+
+ expect(files['maps/test-map_q3e_12345678.bsp']).toEqual(bsp);
+ expect(files['maps/test-map_q3e_12345678.aas']).toEqual(aas);
+ });
+
+});