A camera that follows smoothly stands between pixels most of the time,
and pixel art then wanders between them. The new camera switch draws
what the camera shows on the game view's own grid, while positions in
the model keep their fractions.
- `Pixel perfect` is a camera row, off by default. It works at every
fixed game scale and at Auto, but not at Fit and not in the Scene
tab, where one works with the fractions.
- The grid has one step per device pixel of a world unit. At 100% that
is whole units, at 500% fifths. The view carries it (`View.grid`),
so no view draws with the grid of another.
- The view looks from a point that puts the world's origin on a whole
device pixel (`lookFrom`). This also covers odd camera sizes and
views that cut the picture to an odd size.
- Sprites, texts and tilemaps snap their top left corner when they are
unturned, so an odd size lands on whole pixels too. A turned object
snaps its pivot (`snapPlacement` in `pixelGrid.ts`).
- Under a camera shader the picture is taken the same way, and the
objects in it use the picture's grid. With Camera pixels that is one
step per camera pixel.
- An overlay camera has its own switch and snaps only while the main
picture is at a fixed scale.
- Particles are drawn where they are.
- New test hook `__gm.gameDrawnRect(id)` reads where a sprite's picture
is drawn, which `gameRect` (the model's) cannot tell.
Found: the e2e fixture at 300% drew the crate at y 205.5 device pixels
without moving the camera, because the view cut to an odd height puts
its centre on half a pixel. With the switch on it is at 206.
Tests:
- Gate: 237 files, 3587 tests (+10 in pixel-grid.test.ts)
- e2e 2,3: ALL PASSED, 75 checks (+1, 3j)
- Counter-checks: without the origin snap 3 unit tests fail, with the
centre snapped instead of the corner 2 fail
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three small things asked for in one issue. A scene came up wherever
the view happened to stand, and the inspector carried a button and a
list that are better reached somewhere else.
- A scene comes up framed as if Fit had been clicked: after a
project is loaded and when another scene is opened. From then on
the view belongs to the user.
- An empty scene has nothing to frame and stands at the origin, one
pixel per unit. A scene the running game loads is not framed anew.
- The Container tab is left out. It keeps the view of each container
as before (#171).
- The Unpack button and the two lines under it are gone from the
inspector of a placed container. Unpack container stays in the
right-click menu of the hierarchy and of the scene view.
- The corners of a polygon collider are no row in the inspector any
more. They are edited on the outline in the scene view. The new
schema option noRow keeps a property out of the inspector while it
stays a property.
Tests:
- Gate on melektaus-atx: 219 files, 3401 tests, CHECK EXIT 0.
- One new unit test in tests/core/polygon.test.ts: the corners are
a property of the collider and no row.
- Three new e2e checks: a loaded project is framed like Fit (module
03), an opened scene is (04), and after a real reload a click on
Fit moves nothing (09).
- Module 09 unpacks through the right-click menu now and checks that
the inspector offers no button.
- Full suite on melektaus-atx: 1695 checks, all pass. Smokes 24, 19
and 21, all pass.
- No counter-probe evaluated, and nothing looked at after the build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The released game only ever zoomed in whole steps up to 400%, so a
small camera on a large screen stayed far smaller than it could be.
The "Click to play" caption was drawn in the 11 px panel title size
and was easy to miss.
- GAME_SCALE_STEPS gains 300%, 500% and 600%. The game tab picker,
Auto in the editor and the game link all read the same list.
- The start caption takes its size from the new Theme.captionSize
(16) instead of a constant in GameScreen.
- The player page keeps the default arrow cursor at all times. It no
longer switches to the hand over the caption.
Tests:
- view.test: Auto for 320x180 in 2000x2000 is now 6, with cases
for 3 and 5. play-settings.test takes 2.5 and 8 as invalid steps.
- e2e 60 checks the caption against the panel title size and the
arrow cursor before and after the start. Module 03 follows the new
step list.
- ./check.sh 2808 tests, CHECK EXIT 0 (before the last Theme tweak,
which changes no test). Full e2e run ALL PASSED, 1574 checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the behaviors fixed, the scene Performance test still ran at
about 23 fps with 500 entities. The rest of the frame went to the
editor: every spawn or destroy rebuilt the hierarchy with all its
rows, redrew the scene view behind the game tab and refreshed every
panel. The game itself is cheap. What the panels show of it now
follows a preset in the project settings.
- New section While playing in the project settings with three
presets: Live, Balanced and Minimal. Every project reads as
Balanced, including those saved before the setting existed.
- Live keeps today's behavior. Balanced brings hierarchy and panel
values up to date 4 times a second and stops redrawing the hidden
scene view. Minimal updates hierarchy and values only on pause and
throttles the graph tabs as well.
- The graph and state machine tabs stay live under Balanced. At 4
updates a second the beads on exec edges stutter, and the tabs
draw only while on screen. This differs from the issue proposal.
- The game itself is never throttled. Pause, step and stop bring
every panel up to date at once, and a change of structure that
falls between two updates is kept for the next one.
- The rule lives in editor/playThrottle.ts as panelPolicy and
PanelClock. PlaySession asks it after every frame.
- Objects spawned at the top level go into a hierarchy group named
after their asset, so hundreds of gold pieces fold into one row.
Groups start folded.
Measured on a throwaway copy of Performance test, 3 s each, fps:
- About 500 entities: Live 38.6, Balanced 56, Minimal 60.6.
- About 315 entities: Live 56.9, Balanced 60.5, Minimal 60.6.
- p95 frame time at 500 entities: 60 ms, 41 ms and 23 ms.
Tests:
- ./check.sh: 193 files, 2799 tests, CHECK EXIT 0.
- GM_STEPS=1,18,59: ALL PASSED, 54 checks.
- Full e2e run (local): ALL PASSED, 1562 checks.
- tests/editor/play-throttle.test.ts: presets, update rates, kept
structure changes, flush, spawn group and loading old projects.
- New e2e module 59: preset chosen with the mouse, measured update
rates, scene view behind the game tab, Minimal with pause.
- Module 03 now expects spawned objects in their group row after the
next update.
- Counter-checks: 4 in unit tests and 2 in the browser, each failing
as expected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scene view had no header bar of its own, and its eye sat in the
top bar. Switching between Scene and Game also showed two different
pictures, since nothing tied the free view to the camera.
- Theme.headerPadding (4) replaces Theme.padding in the top bar and
in TabHeader, for the padding and for minHeight. Both bars shrink
from 40 to 32 px. Nothing else uses the new value.
- The new ScenePane wraps the scene view in a header bar with Fit
and the visibility eye. The eye is gone from the top bar, which
keeps the menu, undo and redo.
- Fit with an active camera shows the world exactly as the Game tab
does at its chosen step: the same zoom, and the camera centre on
the same screen pixel. The rule is the pure freeViewOfCamera in
viewport/view.ts, which also mirrors the letterbox snapping.
- Fit without a camera frames all content on a whole step, as
before.
- New hook __gm.sceneHeaderAt(name). e2e 46g opens the eye there.
- The doc comment above Theme.treeIndent went away in the working
tree along with this. It is committed as found.
Not checked: the two views only match while they have the same size.
With the tilemap panel open the scene view is narrower, and Auto may
pick a smaller step there.
Tests:
- ./check.sh: 178 files, 2575 tests, CHECK EXIT 0
- 2 new unit tests in view.test.ts: three cameras times four steps
land on the same screen point as in the Game tab, Auto stays whole
- e2e 3h (2 checks): a real click on Fit at Auto and at Fit, crate,
camera frame and view box match in both tabs
- GM_STEPS=1-3,46: ALL PASSED, 94 checks
- Counter-probes: without the centre offset the unit test fails,
with Fit ignoring the camera both 3h checks fail
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bars around the camera frame in the Game tab were pure black.
They now take Theme.colors.background, so the tab blends in with the
rest of the editor instead of framing the game in a black box.
- GameViewport drops LETTERBOX_COLOR and uses the theme colour for
both the letterbox and the model frame.
- e2e 3f checked "the crate is drawn, not letterbox" against
#000000. With the new colour that would pass whatever is drawn,
so it now compares against #1b1d22.
Tests:
- ./check.sh: 178 files, 2575 tests, CHECK EXIT 0
- GM_STEPS=1-3,46: ALL PASSED, 94 checks (3f reads #e0553f)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both settings lived in the browser's localStorage, so they did not
travel with the project and differed per machine. They now belong to
the project like the symbol scale. There is no migration, old projects
load with the defaults.
- New module src/core/model/playSettings.ts reads playFrom and
gameScale on load. Unknown or missing values fall back to the start
scene and 100%.
- src/editor/settings.ts is gone.
- Play starts in the project settings dialog is an undo step
(SetPlayFromCommand). It is only recorded on a real change.
- The game view scale is saved with the project but is no undo step,
so Ctrl+Z never undoes a zoom. A scale picked during play is written
to the stored project and survives Stop.
- Save states of persistent Variables assets (saves.ts) stay in the
browser on purpose. They are runtime data of the game, not settings.
Tests:
- 6 new unit tests in tests/editor/play-settings.test.ts: defaults,
save and load, broken JSON, undo of Play starts, scale without undo
step, scale picked during play survives Stop.
- e2e 03g checks the value in the project, nothing in localStorage and
no new undo step. 35f also checks project.playFrom. No new checks,
the sum stays at 1375.
- Counter checks: no write to the stored project in play, no read on
load, and an undo step for the scale each fail exactly their test.
- ./check.sh: 158 files, 2332 tests, CHECK EXIT 0
- e2e 1-17,28,30,35,38-40,46: ALL PASSED, 752 checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The game view always stretched the camera picture to fit the pane.
Pixel art came out blurred at odd factors, and there was no way to
see the game at its real size. The view now has a header with a
scale drop-down, and 100 % is the default.
- The header of the new GamePane offers Auto, Fit, 25 %, 50 %,
100 %, 200 % and 400 %. Auto and Fit show the factor they land
on, e.g. "Auto · 100%", and follow the pane when it resizes.
- 100 % means one screen pixel per pixel of the camera resolution
(width x height). The camera zoom scales the world on top of
that, so a zoomed camera still fills the same frame.
- Auto takes the largest fixed step that fits the whole picture
and falls back to 25 % when nothing fits. Fit keeps the old
behaviour.
- A picture larger than the pane is cropped around the camera
centre. Fixed steps start on a whole pixel so pixel art stays
sharp at 100 %.
- The choice is kept in the browser like "Play starts". Nothing
is written into the project.
Found on the way: the first draft applied the step to the world
instead of the camera resolution. The camera zoom then had no
effect at 100 %, which the existing check 12c caught (zoom 1.37
measured as scale 1).
The CI smokes were not run locally, because that would reseed
projects the user works in. All three smoke cameras are 640x360
and fit the 670x383 harness view at 100 %.
Tests:
- ./check.sh: 156 files, 2298 tests, CHECK EXIT 0 (before 2290).
- 8 unit tests in tests/viewport/view.test.ts for whole-pixel
steps, cropping, Auto and Fit with fallback, and zoomed cameras.
- 7 e2e checks in 03g. e2e 1-17,28,30,38-40: ALL PASSED, 727
checks.
- Counter-checks: no cropping, no rounding, no factor reported to
the header, and no zoom share each fail their own tests.
- Check total: 1367 (CI at c1a702b) + 7 = 1374, computed only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A middle-mouse pan used to run the full redraw on every frame. It
collected every object from the project, cleared and redrew every
shape, re-sorted all children and made Pixi re-upload every buffer,
although only the camera had moved. The user saw the frame rate drop
while panning.
- WorldViewport gets moveCamera(). When zoom and view size are
unchanged it only moves the world and redraws the overlays drawn in
screen pixels. collectFrame, the shape redraw and the re-sort drop
out of a pan frame.
- Every full redraw remembers the box each object is culled by. A pan
that moves an object across the edge of the view still takes the
full redraw, so objects appear and vanish as before.
- Grid, selection outlines, rays and the container origin mark move
into a shared drawOnScreen hook. It runs after every full redraw and
after every pan. The parent links stay in afterItems because they
are drawn in world units.
- Wheel zoom keeps the full redraw, because line widths follow the
zoom. A cache for collectFrame was left out: it would save about
1 ms and risk stale data.
- debugInfo() reports redraws and cameraMoves, and __gm gets a hook
that forces a full redraw, so a check can compare against it.
Findings:
- Chrome already coalesces mouse moves to one per frame (500 moves
gave 183 redraws in 189 frames). A throttle was tried and removed
again, because its counter-check did not fail.
- A check in module 03 compared the whole gameInfo() object and broke
on the new counters. It now compares camera, objects and rendering.
- The stall without ?vsync most likely comes from the desynchronized
context in src/main.ts. It cannot be reproduced headless.
Tests:
- Measured on Jump and Run, main-thread time per frame, 3 runs of 240
moves: pan 5.7-7.4 ms before, 3.8-4.8 ms after. Plain mouse moves
stay at 2.1-2.4 ms. 208 of 240 moves took the fast path.
- ./check.sh: 141 files, 2080 tests, exit 0.
- GM_STEPS=1-16,29,42,43: 535 checks, 1 failed (the module 03 test
above). After the fix GM_STEPS=1-5: all passed, 130 checks.
- 3 new e2e checks in 5d: 12 pan steps give 12 camera moves and 0
full redraws, pixel rows after the pan match a fresh redraw, a box
panned out of view and back is drawn again.
- Counter-checks: overlays not redrawn on pan gives "3 of 3 rows
differ". Edge test disabled keeps the box visible with 0 redraws.
- Check total 1285 (1282 confirmed by CI task 229, plus 3).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite spent 88% of its step time asleep: 564 s of steps, 496 s of
them in sleep(n) - fixed spans estimated by hand and then copied from
step to step. That is why it was slow, and why it flickered.
The signal is afterFrames(count, timeout) in ui/render.ts, exposed as
__gm.afterFrames(). It wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and it gives up
after a timeout instead of hanging the run. settle() in the harness
waits two drawn frames (~32 ms) instead of 150-300 ms and falls back to
sleep(150) when the hook is missing (an old build): silently not
waiting would make every later check a coin toss.
Every action goes through it now - click, doubleClick, wheel, drag,
dragAsset, press, typeText, select, mutate, undo, redo - and with that
about 550 sleep() calls behind those actions became redundant, helpers
that are nothing but a mutate in a coat included. What a frame cannot
answer got its own condition instead: waitUntil(expr), play()/stop()
waiting on __gm.state.mode rather than on 300 ms, saved() for the
autosave (the two hand-written polling loops sampled at 300 ms for
something that arrives after a second), and showTab() as a real tool -
47 places called evaluate("__gm.showTab(...)") raw, and 24 of them
waited for nothing at all and read the old panel.
564 s -> 243 s of step time, the waiting share 88% -> 76%. Three kinds
of wait were kept, and each one bit when it was taken away: loading
bytes (textures, fonts), game time that a repaint cannot stand in for
(time scale, shader and particle clocks, the live values a running
graph carries), and the inertia the scroll step measures on purpose.
Two things the new speed uncovered, both real: 25-animation read the
frame menu unfiltered and had frozen an incomplete list (the CI failure
on c990dbc), and 33-render-on-change measured idleness while the
autosave of the step before was still painting - it waits for
saveStatus to leave dirty/saving now, so it measures that nothing
happens any more instead of that it waited long enough.
- `.gitignore` said `index.html` unanchored, which also caught the Vite source page;
a fresh clone could neither `dev` nor `build`. Anchored to `/index.html`, page added.
- Removed GM_REMOTE (tools/remote/gm-browser.ps1 and the ssh/scp branch in browser.mjs):
the Windows test machine is gone and the only flaky check that lived on it is closed (#37).
- Removed `npm run preview`, undocumented and unused.
- Comments: wrong number in collider.test.ts (250 → 200), the "declarations used to…"
history in listItems.ts and friends, the 24 px/28 contradiction, section banners in
nodes/index.ts, node-catalog.test.ts and api/index.php, name paraphrases, and the stale
step numbers in tools/e2e/11-…23- from the old global count.
- README: asset types include `animation`, kind order Number/Text/Boolean, tagged colour
and texture items, 6/5 kinds, the `Texture` node, `./build.sh`, PROJECT_FORMAT_VERSION,
and the file tree (kinds.ts, listItems.ts, propertyPorts.ts, Breadcrumb.ts, beads.ts,
tools/assets/).
- tests/helpers.ts takes the duplicated `findObject` and `sceneWithCamera`.
Proof: ./check.sh 797 tests, full e2e ALL PASSED 633 checks against this bundle.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
buildStructBlock only covered structs, so a placed instance drew numbers,
text, bools and lists through createFieldRow on a 64 px name column while
its struct block sat on the asset's grid below it. variableRows.ts now
builds the row for every variable kind on both sides; the edit option
decides what the owner may change (asset: rename, x, item kind; instance:
revert and push). Label and Button take a fixed width so the mark column
no longer shifts names when a list shows its item-kind picker.
The two structFields calls that became dead with 9f48257 are gone. The
e2e steps locate the Enabled toggle through __gm.inspectorField instead
of clicking a fixed offset from its label.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The game tab held a "Press Play" hint where the picture belongs. It now
previews the scene through the active camera, and says "No active camera"
when none is switched on.
Camera choice and its world position moved into activeCamera.ts so both
are testable without Pixi. Entity.x/.y are parent-relative; the viewport
used them as the camera's spot, which framed a parented camera at the
wrong place.
The game view took whatever camera came first in the scene, ignoring the
switch, so `enabled = false` on a camera did nothing and a second camera
never got its turn. `World.activeCamera()` now decides: of the cameras
switched on – the whole parent chain counted, as for any object – the
first in hierarchy order, and none on renders nothing at all. It is asked
per frame, so a graph switching cameras hands the view over immediately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Container tab reuses SceneViewport against any object holder, so a
container template is edited exactly like a scene. Right-click no longer
changes the selection, which previously made nesting impossible: opening
the menu on a container loaded it, and "Place in container" then targeted
itself.
Template edits (move, rename, override) now reach already-placed instances
per slot, through a shared templateSync used by both the apply path and the
plain object commands. Copies the user has touched keep their own value.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tooling:
- Prettier as the single formatting authority (110 columns, single quotes,
measured against the existing style); Markdown excluded because table-cell
padding turns the README glossary into 1000-character lines.
- Type-aware ESLint ending in eslint-config-prettier, so a rule always means
"this is a bug", never "this looks different".
- .editorconfig and an `npm run check` that runs typecheck, lint, format and
tests in one go.
Findings fixed on the first run: three more duplicate imports, four
String(unknown) calls (parameter types narrowed instead of casts), two dead
initialisers, two redundant casts, a console.log for a real error. Two
warnings were false here and are disabled with a reason in place.
Tests 132 -> 269:
- node-catalog.test.ts covers every node with a run(), with a guard that fails
when a new node has no row.
- behavior-events.test.ts covers the ten event nodes through a running World,
checking timing rather than existence.
- Input's state machine (press/release/releaseAll) is reachable without DOM
events now; the DOM handlers are thin adapters.
Compare fell back to == without a configured operator although the schema
default is <; aligned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>