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

+62
View File
@@ -0,0 +1,62 @@
/**
* One run of a game: the world of the scene it is in and the systems that play it. Shared by the
* editor's Play and a released game (#266), so both build, swap and end a scene the same way.
*/
import type { SaveArea } from '../core/graph/savedValues';
import type { Project } from '../core/model/Project';
import type { AudioSink } from './systems/Audio';
import { addGameSystems, type BehaviorSystem, type TimeScaled } from './systems/BehaviorSystem';
import type { Input } from './systems/Input';
import type { Rapier } from './physics/engine';
import { World } from './World';
export interface RunServices {
audio: AudioSink;
physics: Rapier;
clock: TimeScaled;
saves: SaveArea;
}
export class GameRun {
private constructor(
private readonly project: Project,
private readonly input: Input,
private readonly services: RunServices,
public world: World,
public behaviors: BehaviorSystem,
) {}
static start(project: Project, sceneId: string, input: Input, services: RunServices): GameRun {
const world = World.instantiate(project, sceneId);
return new GameRun(project, input, services, world, addGameSystems(world, project, input, services));
}
/**
* One frame. `awake` is the step the clock would have moved without the game's own pause, which
* stops the clock but not the behaviors that run while paused. A `Load scene` is carried out here;
* stopping and restarting are the host's business, so they are only reported.
*/
tick(step: number, awake: number): 'stop' | 'restart' | 'scene' | null {
const world = this.world;
world.tick(world.paused ? awake : step);
this.input.endFrame();
if (world.stopRequested) return 'stop';
if (world.restartRequested) return 'restart';
if (!world.loadSceneRequested) return null;
this.loadScene(world.loadSceneRequested);
return 'scene';
}
/**
* 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.
*/
loadScene(sceneId: string): void {
const carried = this.behaviors.shared;
this.world.destroy();
this.world = World.instantiate(this.project, sceneId);
this.behaviors = addGameSystems(this.world, this.project, this.input, { ...this.services, carried });
}
}