Demo: the platformer in a browser
Ascent, running on flutter3d_webgl. Same engine, same level files, same simulation; the only thing that changed is which backend the application asks for.
The title card is the first thing you meet, and it names the terms: two hundred and sixty metres, three lives, and a summit. A run is five levels, not one: the game opens on first_steps.json and each document names the next, so the chain runs First Steps → Ascent → Cisterns → Foundry → Spire, and the lives, deaths, elapsed time and coins are carried across all of them. Three lives are three lives for the run; spending the last one ends it and clears the save. Ascent is the level the game is named after and the one this page's pictures come from, but it is the second of five.
Click the frame first; the keyboard goes to whatever was clicked last. It takes a few seconds to start: the level's textures and the runner's .glb are fetched before the first frame. Until the model arrives the runner is an orange box the size of its own collider, and the box stays if the model will not load, so a missing asset never stands between you and playing.
Controls #
- W A S D
- Run, read against the camera
- Space
- Jump. Tap for a short one, hold for a full one. That is
jumpCut. Again in the air for the double jump - Mouse
- Turn the camera. Click once and the browser hands the pointer over; Escape gives it back. Where it will not, on a phone or in a browser that refuses, a drag turns the camera instead
- Click or Q
- Dash. The click is the dash wherever the pointer is captured, which now includes a desktop browser. Q is there for everywhere else
- Ctrl or C
- One key, four verbs, decided by what the runner is doing: a ground pound in the air, a drop through a one-way platform standing on one, a slide at speed, a crouch otherwise
- Shift
- Sprint
- Escape
- Settings, including rebinding. On desktop it also gives the mouse back
- R
- Start again, once a run is over
- A gamepad
- Works throughout, and any button takes the title card down. South face jumps, east dashes, the left shoulder is the crouch key, the left stick clicks to sprint, Start opens the settings and restarts a finished run
Wall jumps, mantles, slides, long jumps and ground pounds are all in there; the full list of what each one costs is in what a platformer adds.
What is actually different #
Three files, and none of them is in the engine.
// lib/src/backend.dart — the whole of the choice
export 'backend_native.dart' if (dart.library.js_interop) 'backend_web.dart';
A conditional import instead of a runtime branch, because the two backends pull in incompatible worlds: flutter_gpu does not compile for the web and dart:js_interop does not compile for macOS, so a file that imported both could target neither.
// lib/src/backend_web.dart
import 'package:flutter3d/flutter3d.dart';
import 'package:flutter3d_webgl/flutter3d_webgl.dart';
// A browser without WebGL2 completes the future with a
// DeviceUnavailableException that says so.
Future<GraphicsDevice> openDevice({required int width, required int height}) =>
// The shaders default to the engine's own: GLSL ES 3.00, translated from
// `flutter3d_shaders` by `flutter3d_webgl/tool/generate_shaders.dart`
// (`webGlEngineShaders`). There is no compiled bundle here: a browser
// compiles GLSL itself, so a "bundle" is a map from the engine's entry
// point names to source text.
WebGlDevice.open(width: width, height: height);
Everything above that line is the ordinary frame: a Scene, a CameraNode, a CameraView, Renderer.render. The simulation does not know a browser is involved and neither does the renderer.
What the browser costs #
Three entries used to be here and are not any more. The page claimed them for months, so they are named below.
Pointer capture works. flutter3d_pointer_lock grew a browser backend over document.requestPointerLock, so the camera is the mouse. A page embedding the game needs allow="pointer-lock" on the iframe; this one has it.
Sound plays. flutter_soloud ships a WebAssembly build and starts here.
Settings and saves survive a refresh. They live in localStorage now. Before that they were files, so every launch was a first launch and nothing said so.
| Fixed resolution | A WebGlDevice owns the canvas it was created with, and a WebGL canvas resets its drawing buffer when resized. So the frame is drawn at 1280×720 and the element is stretched to the layout by CSS, which is why presentFrame takes a BoxFit |
| Download | About 55 MB on a first load, most of it textures, models and the CanvasKit runtime. Read once off the deployed build; the browser and the date were not written down, so it is an order of magnitude rather than a figure to hold anything to |
The bug this demo found #
The WebGL backend shipped with every shader entry point the engine asked for and could not draw a single sphere.
lib/engine_shaders.dart is generated (flutter3d_shaders translated to GLSL ES 3.00 by tool/generate_shaders.dart), and at the time nothing checked that it was current; its own header said as much. Cascaded shadows added shadow_matrix_far to the fragment uniform block, the generated file still had the old one, and the failure was exactly the one the HAL's contract names:
Bad state: uniform block "FragInfo" has no member "shadow_matrix_far".
It has: light_position, light_color, light_direction, light_cone, base_color,
emissive, camera_position, material, material2, frame_params, shadow_params,
shadow_matrix. The engine and the shader disagree about this block.
Re-running the generator fixed it. A caller naming a member the block does not have is an error rather than a zero, which is the one direction of that mismatch that fails loudly, and it is why the block is checked by name at all. The other direction, a shader reading a member nobody wrote, gets zeros and draws an unlit scene with nothing reported anywhere.
That generated file is the seam's weak point, by design: the compiled Impeller bundle makes the same bargain. Both are build outputs that could go stale without anyone noticing, and both are watched now, because this bug was not the last of its kind. tool/ci.sh regenerates the translation and fails on the diff, which is how the table was found holding a sky shader from before the sky was rewritten. The bundle cannot be diffed, since it is a binary nothing on CI can build, so it is held to freshness instead: the the compiled shader bundle is not older than its sources rule compares it against the GLSL it was built from, for the engine's bundle and the demo's alike.
Building it yourself #
# Re-translate the shaders if the engine's GLSL has changed.
(cd packages/flutter3d_webgl && dart run tool/generate_shaders.dart)
(cd apps/flutter3d_demo_platformer && flutter build web --release)
python3 -m http.server 8000 --directory apps/flutter3d_demo_platformer/build/web
Next #
- Demo: the shooter: the same swap, on a first-person game
- Writing a HAL backend: the contract this backend implements
- Tutorial: build a platformer: how the game itself is put together