A project could only be played inside the editor, behind the login.
To show a game to someone else there was no way short of an account.
A release freezes the saved project under a random token and serves
it on a page that holds nothing but the game view.
- Menu -> Release... opens a dialog with the state of the release,
the link with Copy link and Open, Publish or Update release, and
Take down behind a confirmation.
- Publish saves first and refuses while anything is unsaved, like
snapshots. Updating keeps the link. Take down kills it, and a new
Publish hands out a new token.
- The link is play.html?game=<32 hex>. The page needs no session,
paints Theme.colors.background, zooms with Auto and waits for
"Click to play" so that sound and keyboard focus are allowed.
- The game always starts in the start scene. Persistent variables
are stored per link in the browser. Plugins are not loaded, and the
dialog warns about them and about a project without a scene.
- GET ?r=releases/{token} is the only route besides auth/* that works
without a login. The public answer carries the token as project.id,
so the project id stays hidden. Release files are written with 0640
and nginx does not serve them directly (measured 403).
- Starting, the frame and Load scene moved into runtime/GameRun.ts.
Play in the editor and the play page run through the same code.
- WorldViewport moved to src/viewport/ and no longer needs the
EditorState. Pointer forwarding (gamePointer.ts) and setting up
textures, fonts and sounds (projectFiles.ts) exist only once.
- Vite builds two pages. The play page loads 4 kB of its own code
plus the shared chunk (Pixi, runtime, drawing, 653 kB) and Rapier,
without the editor.
Found on the way, not fixed here: data/projects/<id>.json answers 200
without a login because the files are 0644.
Tests:
- ./check.sh: 194 files, 2808 tests, exit 0.
- tests/php/releases.test.php: 20 checks, also added to CI.
- New e2e module 60 with 10 checks, played in a browser without a
session. Module 46 counts the menu entries and was updated.
- Full e2e run: 1572 checks passed.
- Counter-checks: 3 unit, 2 PHP and 3 browser mutations all fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
157 lines
6.4 KiB
TypeScript
157 lines
6.4 KiB
TypeScript
/**
|
||
* Released games (#266): the link a project is played by, what the release dialog says about it,
|
||
* and the run a released game shares with the editor's Play – one way to start, tick, swap scenes
|
||
* and end, whichever page it is on.
|
||
*/
|
||
import { beforeAll, describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||
import type { SavedValues } from '../../src/core/graph';
|
||
import type { PropertyBag } from '../../src/core/model/Properties';
|
||
import { releaseToken, releaseUrl, type ReleaseSummary } from '../../src/core/store/ReleaseStore';
|
||
import { releaseState, releaseWarnings } from '../../src/editor/releases';
|
||
import { saveArea, saveKey } from '../../src/editor/saves';
|
||
import { snapshotBlocker } from '../../src/editor/snapshots';
|
||
import { GameRun, type RunServices } from '../../src/runtime/GameRun';
|
||
import { loadPhysics, type Rapier } from '../../src/runtime/physics/engine';
|
||
import { SILENT_AUDIO } from '../../src/runtime/systems/Audio';
|
||
import { Input } from '../../src/runtime/systems/Input';
|
||
import {
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeSpriteScene,
|
||
makeVariables,
|
||
place,
|
||
type SceneFixture,
|
||
} from '../helpers';
|
||
|
||
const TOKEN = '0123456789abcdef0123456789abcdef';
|
||
|
||
let physics: Rapier;
|
||
beforeAll(async () => {
|
||
physics = await loadPhysics();
|
||
});
|
||
|
||
describe('the link of a release', () => {
|
||
it('leads to the play page with the token, and the play page reads it back', () => {
|
||
const url = releaseUrl('https://nockal.com', TOKEN);
|
||
expect(url).toBe(`https://nockal.com/game-maker/play.html?game=${TOKEN}`);
|
||
expect(releaseToken(new URL(url).search)).toBe(TOKEN);
|
||
});
|
||
|
||
it('takes nothing from an address that carries no token of the right shape', () => {
|
||
expect(releaseToken('')).toBeNull();
|
||
expect(releaseToken('?game=')).toBeNull();
|
||
expect(releaseToken('?game=2072b56583af')).toBeNull();
|
||
expect(releaseToken(`?game=${TOKEN.toUpperCase()}`)).toBeNull();
|
||
expect(releaseToken(`?game=../${TOKEN}`)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('the release dialog', () => {
|
||
const release = (revision: number): ReleaseSummary => ({
|
||
token: TOKEN,
|
||
revision,
|
||
publishedAt: '2026-09-29T10:39:31Z',
|
||
});
|
||
|
||
it('says whether the saved project has moved on since the copy was taken', () => {
|
||
expect(releaseState(null, 3)).toMatch(/^Not released/);
|
||
expect(releaseState(release(3), 3)).toMatch(
|
||
/^Released 2026-09-29 \d\d:39 · the link plays the project as saved$/,
|
||
);
|
||
expect(releaseState(release(3), 5)).toMatch(/the project has changed since$/);
|
||
});
|
||
|
||
it('warns about a project without a scene and about plugins, and about nothing else', () => {
|
||
const f = makeSpriteScene('Box');
|
||
expect(releaseWarnings(f.project)).toEqual([]);
|
||
f.project.plugins = ['inventory.js'];
|
||
expect(releaseWarnings(f.project)).toEqual([expect.stringMatching(/^Plugins are not loaded/)]);
|
||
f.project.plugins = [];
|
||
f.project.assets = f.project.assets.filter((a) => a.type !== 'scene');
|
||
expect(releaseWarnings(f.project)).toEqual([expect.stringMatching(/no scene/)]);
|
||
});
|
||
|
||
it('refuses to publish what is not saved, like a snapshot', () => {
|
||
expect(snapshotBlocker('saved', 'release')).toBeNull();
|
||
expect(snapshotBlocker('dirty', 'release')).toMatch(/not saved yet/);
|
||
expect(snapshotBlocker('error', 'release')).toMatch(/nothing current to release$/);
|
||
expect(snapshotBlocker('error')).toMatch(/nothing current to take a snapshot of$/);
|
||
});
|
||
|
||
it('keeps a released game’s saves under its link, apart from the project’s', () => {
|
||
const store = new Map<string, string>();
|
||
const memory = {
|
||
getItem: (key: string) => store.get(key) ?? null,
|
||
setItem: (key: string, value: string) => void store.set(key, value),
|
||
removeItem: (key: string) => void store.delete(key),
|
||
};
|
||
const saved: SavedValues = { vars: { score: 3 } };
|
||
saveArea(TOKEN, 'release', memory).write(saved);
|
||
expect([...store.keys()]).toEqual([saveKey(TOKEN, 'release')]);
|
||
expect(saveArea('project', 'play', memory).read()).toEqual({});
|
||
});
|
||
});
|
||
|
||
describe('a run of the game', () => {
|
||
function services(): RunServices {
|
||
const saved: SavedValues = {};
|
||
return {
|
||
audio: SILENT_AUDIO,
|
||
physics,
|
||
clock: { scale: 1 },
|
||
saves: { read: () => saved, write: (values) => Object.assign(saved, values) },
|
||
};
|
||
}
|
||
|
||
/** A behavior below a box that runs `type` on its first frame. */
|
||
function onStart(f: SceneFixture, type: string, params: PropertyBag = {}) {
|
||
const behavior = makeBehavior(f, 'Logic');
|
||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||
place(f, behavior.id, { x: 0, y: 0 }, box);
|
||
const edit = graphEditor(f, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const node = edit.addNode(type, params);
|
||
edit.connect(start, 'exec', node, 'exec');
|
||
}
|
||
|
||
it('reports nothing while the game just runs', () => {
|
||
const f = makeSpriteScene('Box');
|
||
place(f, f.spriteId, { x: 0, y: 0 });
|
||
const run = GameRun.start(f.project, f.sceneId, new Input(), services());
|
||
expect(run.tick(1 / 60, 1 / 60)).toBeNull();
|
||
expect(run.world.entities.map((e) => e.name)).toContain('Box');
|
||
});
|
||
|
||
it('reports a Stop game and a Restart game for the page to act on', () => {
|
||
const stopping = makeSpriteScene('Box');
|
||
onStart(stopping, 'StopGame');
|
||
expect(
|
||
GameRun.start(stopping.project, stopping.sceneId, new Input(), services()).tick(0.016, 0.016),
|
||
).toBe('stop');
|
||
|
||
const restarting = makeSpriteScene('Box');
|
||
onStart(restarting, 'RestartGame');
|
||
expect(
|
||
GameRun.start(restarting.project, restarting.sceneId, new Input(), services()).tick(0.016, 0.016),
|
||
).toBe('restart');
|
||
});
|
||
|
||
it('carries out a Load scene itself, and carries the shared variables into the new world', () => {
|
||
const f = makeSpriteScene('Box');
|
||
const second = new CreateAssetCommand('scene', 'Level 2');
|
||
f.stack.execute(second);
|
||
const vars = makeVariables(f, 'Game', ['score']);
|
||
onStart(f, 'LoadScene', { scene: second.asset.id });
|
||
|
||
const run = GameRun.start(f.project, f.sceneId, new Input(), services());
|
||
const first = run.world;
|
||
run.behaviors.liveVariables(vars.id)!.score = 7;
|
||
expect(run.tick(0.016, 0.016)).toBe('scene');
|
||
expect(run.world).not.toBe(first);
|
||
expect(run.world.sceneId).toBe(second.asset.id);
|
||
expect(run.behaviors.liveVariables(vars.id)?.score).toBe(7);
|
||
expect(run.world.entities.some((e) => e.name === 'Box')).toBe(false);
|
||
});
|
||
});
|