Share a link that plays the game without the editor (#266)

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 13:30:38 +02:00
1 parent 94c1055895
commit 4194f3e150
42 files changed
+1600 -246

No files matched your search

+34 -53
View File
@@ -5,18 +5,14 @@
*/
import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph';
import { namedValue } from '../core/graph/values';
import {
addGameSystems,
type BehaviorSystem,
behaviorError,
type Trace,
} from '../runtime/systems/BehaviorSystem';
import { GameRun, type RunServices } from '../runtime/GameRun';
import { type BehaviorSystem, behaviorError, type Trace } from '../runtime/systems/BehaviorSystem';
import type { AnimationPlayer } from '../core/model/animation';
import type { PropertyBag } from '../core/model/Properties';
import { gameClock } from '../core/time';
import { loadPhysics, readyPhysics } from '../runtime/physics/engine';
import { Input } from '../runtime/systems/Input';
import { World } from '../runtime/World';
import type { World } from '../runtime/World';
import { ui } from '../ui';
import { soundLibrary } from '../viewport/SoundLibrary';
import { saveArea } from './saves';
@@ -58,8 +54,7 @@ export interface PlayHost {
}
export class PlaySession {
private world: World | null = null;
private behaviors: BehaviorSystem | null = null;
private run: GameRun | null = null;
private readonly input = new Input();
/** Set while Play waits for the physics engine, so a second press does not queue a second run. */
private waiting = false;
@@ -78,6 +73,14 @@ export class PlaySession {
});
}
private get world(): World | null {
return this.run?.world ?? null;
}
private get behaviors(): BehaviorSystem | null {
return this.run?.behaviors ?? null;
}
get isPlaying(): boolean {
return this.world !== null;
}
@@ -146,47 +149,26 @@ export class PlaySession {
// A run starts at second zero, whatever the editor's previews have been counting up to.
gameClock.reset();
const runtime = this.state.enterPlay();
const world = World.instantiate(runtime, scene.id);
this.world = world;
const services: RunServices = {
audio: soundLibrary,
physics,
clock: gameClock,
saves: saveArea(runtime.id, 'play'),
};
this.run = GameRun.start(runtime, scene.id, this.input, services);
this.updates = { structure: 0, values: 0 };
this.input.attach();
this.behaviors = addGameSystems(world, runtime, this.input, {
audio: soundLibrary,
physics,
clock: gameClock,
saves: saveArea(runtime.id, 'play'),
});
this.host.gameView.setWorld(world);
this.host.gameView.setWorld(this.run.world);
this.host.showGame();
this.state.setNotice(this.playNotice(), 'quiet');
if (world.stopRequested) this.stop();
if (this.run.world.stopRequested) this.stop();
}
/**
* Swaps the running scene for another one, inside the same run: the world is torn down and built
* again, while what belongs to the run – the clock, the shared variables, the global machines –
* carries on. The sounds of the old scene end with it, because the behaviors playing them are
* destroyed; a voice that was started detached is nobody's and plays on.
*/
private loadScene(sceneId: string): void {
const old = this.world;
const runtime = this.state.runtimeProject;
if (!old || !runtime) return;
const carried = this.behaviors?.shared ?? null;
const physics = readyPhysics();
old.destroy();
const world = World.instantiate(runtime, sceneId);
this.world = world;
this.behaviors = addGameSystems(world, runtime, this.input, {
audio: soundLibrary,
carried,
physics,
clock: gameClock,
saves: saveArea(runtime.id, 'play'),
});
this.host.gameView.setWorld(world);
/** A `Load scene` swapped the world (see `GameRun.loadScene`). */
private sceneLoaded(run: GameRun): void {
this.host.gameView.setWorld(run.world);
this.state.setNotice(this.playNotice(), 'quiet');
if (world.stopRequested) this.stop();
if (run.world.stopRequested) this.stop();
}
private waitForPhysics(): void {
@@ -222,8 +204,7 @@ export class PlaySession {
soundLibrary.setSuspended(false);
soundLibrary.stopAll();
const world = this.world;
this.world = null;
this.behaviors = null;
this.run = null;
this.input.detach();
ui().focus.blur();
world?.destroy();
@@ -240,8 +221,9 @@ export class PlaySession {
* behaviors that run while paused.
*/
tick(step: number, awake: number): void {
const world = this.world;
if (!world) return;
const run = this.run;
if (!run) return;
const world = run.world;
if (this.state.paused) {
// Keys and clicks that happen while paused are dropped instead of piling up – otherwise
// resuming would deliver every one of them in a single frame.
@@ -251,20 +233,19 @@ export class PlaySession {
}
const wasPaused = world.paused;
world.held = this.state.heldObjectIds;
world.tick(world.paused ? awake : step);
this.input.endFrame();
const request = run.tick(step, awake);
if (world.paused !== wasPaused) this.state.setNotice(this.playNotice(), 'quiet');
if (world.stopRequested) {
if (request === 'stop') {
this.stop();
return;
}
if (world.restartRequested) {
if (request === 'restart') {
this.stop();
this.play();
return;
}
if (world.loadSceneRequested) {
this.loadScene(world.loadSceneRequested);
if (request === 'scene') {
this.sceneLoaded(run);
return;
}
// Mirror what the systems did into the runtime copy every frame, even while the panels wait: an