good 0.2.0
good: ^0.2.0 copied to clipboard
Dimension-agnostic game engine kernel for Flutter: an ECS, native memory pools and ring buffers, scenes, a fixed-tick simulation isolate, hierarchy, assets and the GameView widget.
0.2.0 #
Two gaps 0.1.0 admitted are closed: a column can be declared by the field that holds it, and system order is worked out from the constraints instead of by sorting. Several checks that used to let a mistake through now stop it.
Breaking #
getScene<S>()is nowsingleScene<S>(), onGameStateand onGameSystem. Rename the call; nothing else changes. It always threw once a second scene was resident, and several scenes at once is ordinary here — a level plus a HUD, a pause menu over a game — so the old name described a call that worked right through development and then threw on the first tick after something loaded a HUD. The name is the only place that precondition is visible at the call site. A game with more than one scene loaded reaches them throughloadedScenesor the handleloadScenereturned, and asks an entity which scene it belongs to withentity.sceneSlot.Component.getScene, which resolves the scene that entity was registered with, is unaffected and keeps its name.- The profiler is out of the engine.
GameStateno longer publisheslastSimulationMicros,lastSystemMicros,lastPresentationMicrosor their best-of rings. A game framework is not a profiler, and anything that wanted those numbers can time the part it cares about. The clocks that run the engine stay, including the twoFrameMeters behindfpsandjank. Accessoris bound toComponent.entity<String>()no longer compiles.Accessoralso gained acomponentgetter, which is what an extension on it reaches for on nearly every line.SingleQuery.componentis abstract. It was athrow UnimplementedError()on the public class while the only implementation always supplied it.describeType,describeAssets,describeStruct,describeEventsanddescribeCommandare@mustCallSuper. An override that drops the base pass contributed nothing and said nothing;EntityStruct,GameStateandSceneStructall did it withdescribeEvents. For a component mixin,good generatenow fails on it outright.- Indexing a column with an entity of another archetype throws. The access path resolved a row from the page index and the row offset alone, so a column subscripted with the wrong entity read or overwrote whichever live row sat at that offset in its own storage. The message names the entity's archetype, the column's, and the column. Debug builds only, like the other guards below.
- A hierarchy edge cannot cross a scene. A scene's pages are freed
wholesale, so an edge spanning two of them left the surviving side naming a
freed row.
unloadScenenow unlinks the edges leaving the scene, after the unmount events, so a listener still reads the hierarchy as it stood. - Eleven per-access and per-spawn guards are asserts. A release build no
longer throws on them, because each was answering a question settled by the
shape of the code and not by the running game. The two that cost most were
the array bounds check on every element get and set, and the acyclicity walk
on every
addChild,adoptand declared-child spawn.
Added #
-
Seeded random streams. The kernel had no randomness, so a game would reach for
dart:math'sRandom()— seeded from the clock, invisible to the engine, different on every machine, and impossible to retrofit once shipped games depend on it (#125).late final RandomStream loot; @override void describeRandom(RandomDescriptor descriptor) { super.describeRandom(descriptor); loot = descriptor.has(); }Declared like every other handle and kept in a field — there are no stream names and nothing to look up. Streams are independent, which matters more than it sounds: with one shared stream a system that draws a different number of times shifts every draw after it, and the engine now disables a system by itself when one throws, so that happens without anyone editing the drawing code.
RandomStream.intFor(entity, max)is the per-entity form, and it is a hash of the seed, the stream, the tick and the entity rather than a draw. So it does not depend on how many entities exist or who was asked first, and a scene loading or unloading cannot shift it.The seed is
Game.randomSeed, an overridable member likepageSize. Back it with a final field to supply a recorded one. It is part of a save: recording inputs without it reproduces nothing.The algorithm is written out in the engine rather than taken from
dart:math.Randomgives no guarantee that a seed produces the same sequence on a different Dart SDK, so a replay could stop matching after an upgrade with nothing in the game having changed. SplitMix64's constants are now part of the engine's contract, and changing one is a breaking change to every recorded replay.Only the simulating copy may draw. A draw on the handle the main isolate holds throws, naming the copy.
This is not deterministic replay, and does not deliver it. A replay also needs the player's input recorded per tick, the tick each command landed on, and an answer for asset loads finishing at a different moment — and control commands are explicitly unordered against tick-delivered ones, so their arrival is not reproducible either. See #63.
-
pause,resume,setTimeScaleandstepOncetravel as commands. They were four hand-rolled string tags on the control port; they are now ordinary receipt-delivered commands (#142). Nothing changes at the call site, and the behaviour is the same — the tests for #117 and #124 pass unmodified — but there is one less bespoke channel between the isolates, and the four tags are gone.Receipt-delivered because each one can stop the fixed tick. A tick-delivered command is pumped from
runFixedStep, so the message that started the tick again would be waiting on the tick it stopped.The engine now declares four commands of its own, before anything a game declares, so a game's commands sit after them in the declaration order. That order is internal wire identity and both isolate copies agree on it, so a game sees no difference — but
good_nethashes the declaration order into its handshake, so a networked build of this version will not accept a peer built against the previous one. -
A command can be delivered when the message arrives instead of on the next tick. Register the handler with
hasControlSinkorhasControlSignalinstead ofhasSink/hasSignal(#142).// in GameState.describeCommands descriptor.hasControlSink(setTimeScale, (s) => state.timeScale = s);A normal command is pumped from
GameState.runFixedStep, so it arrives only if the tick runs. That is right for gameplay — a command-spawned entity is visible to every system on the tick its command lands — and useless for anything that stops the tick, because the message that starts it again would be waiting on the tick it stopped. A control command is carried over the control port and run from the port callback, with no tick involved.Four things are true of it that are not true of
hasSink, all following from there being no tick:- Its future completes on send, not on execution.
awaitmeans "handed to the port", not "done". There is no reply leg, because a reply would be pumped inside the tick window this exists to work without. - Its handler must not write component data. There is no open write slot
outside a tick, so a write would be erased by the next
beginTickwith nothing said. A debug assert catches it. - That assert has one hole: it stays silent while a page has never published, which is scene bootstrap and nothing else. A running game is covered.
- No ordering against ordinary commands. Two calls sent in order can run in either, since they travel by different carriers.
hasControlHandlerandhasControlSupplierexist and always throw. A receipt-delivered command cannot answer, so the names that promise a reply fail where they are written rather than hanging where they are called.CommandDescriptorgained these four methods. Nothing outside the engine implements it, so this affects no game. - Its future completes on send, not on execution.
-
Pause, time scale and single-step. There was no way to pause a game, run it in slow motion, or advance it one tick (#124).
game.setTimeScale(0.25); // quarter speed game.pause(); // and stopped game.stepOnce(); // exactly one fixed tick game.resume(); // back at quarter speedCallable from the main isolate, because that is where a pause button lives;
GameState.timeScale,.pausedand.stepOnce()are the same controls on the simulating side. Pause and scale are separate state, so a game paused at half speed comes back at half speed.The scale changes how often a fixed tick happens, never how big one is. Every
onFixedUpdatestill represents exactlyGame.fixedTimeStepat every scale — a fixed timestep means a constant step, and that guarantee is why anything integrating over it is stable. So there is nodtparameter to scale and none was added.For the same reason a
timeScaleof0runs no fixed ticks at all, rather than ticks with a zero-size step: nothing divides by zero and no system sees a step it was not written for.There is no
unscaledDtto look for either, because both clocks already exist under other names. The fixed loop is scaled simulation time; aTickable'sonTick(Duration)is real wall clock and keeps running while the simulation is stopped. Anything that must ignore pause and scale — a UI animation, a network heartbeat, an autosave timer — is aTickable, which is where it already belonged. Presentation running while paused is also what lets a pause menu draw itself.Two edges worth knowing. A negative scale is rejected with an assert, since nothing here is reversible and a negative delta would corrupt the step arithmetic rather than rewind anything. And a large scale meets the existing
maxFixedStepsPerAdvanceguard: a frame affords at most 5 steps however much scaled time it earned, so scales past about 5 run the game slower than asked instead of faster. Raise that cap if a game genuinely needs fast-forward; it is deliberately unchanged here, because it is what stops a slow machine spiralling.Independent of
pauseWhenHidden: a game paused here stays paused across being hidden and shown again. -
The game reacts to the app being hidden. Nothing in the engine knew the app had been backgrounded, so a game went on simulating at its fixed tick while nobody was looking at it — battery spent on a world off screen (#117). The fixed tick now stops when the app is hidden and starts again when it comes back. Override
Game.pauseWhenHiddentofalsefor a game that has to keep running unattended: a live server-authoritative session, a download, a timer the player expects to have advanced.A system hears it by mixing in
AppVisibilityListener, which getsonAppHidden()andonAppShown(Duration gap).Visibility, never focus. Flutter's five
AppLifecycleStates collapse to two, andinactivecounts as visible: a window losing focus, a phone call, the notification shade, the app switcher. Pausing on those is why some games stop when you alt-tab.There is no "about to be killed" hook, deliberately.
onAppHiddenis the last reliable moment and it is a real one — iOS and Android both synthesisehiddenbeforepaused— so a save goes there.detachedgets no callback: it is also the state an app is in before it starts, a killed process never sends it, and no platform promises time to act on it.On the accumulator, one correction worth stating because it is easy to assume otherwise: a long absence never queued a long catch-up.
advancealready capped a single frame atmaxFixedStepsPerAdvanceand dropped the rest, and it leaves under one step behind it, so the burst a resume could produce was never proportional to the time away. Stopping the tick is what saves the battery; discarding the leftover on the way back is worth one step, not five, and that is the step this no longer spends. -
AudioClipis a kernel type, and the kernel registers its decoder. It was ingoo2d, which put sound behind a 2D renderer for no reason it could defend: a clip is bytes and a container name, with no canvas, device or dimension in it. Agoo3dproject could load nothing at all as a result (#93). Every engine package re-exports the kernel, soAudioClip,AudioLoader,AudioKeyandAudioAssetare named exactly where they were for agoo2dgame and are now reachable from a 3D one.Gameregisters the decoder itself, so no game declares anything to get it. Still no playback. -
Game.describeAssetLoadersregisters a payload type's decoder. It joinsdescribeState,describeScenes,describeCommands,describeBuffersanddescribeCameras, chains throughsuperthe same way, and is the one of that family that runs on the decoding isolate only -AssetLoadersis a per-isolate static, and the game isolate holds payload-free declarations and never decodes. Registering a type the layer below already covers replaces it, so a game can substitute its own decoder for an engine one.AssetLoadersalso gainedisRegistered<T>(), which answers whatof<T>()could only answer by throwing. -
A column can be declared by the field that holds it.
final speed = Field.float64(220)replaces alate final DataPointer<double>paired with adescribeStructbody a few lines down. Both forms work: a column whose default comes fromdescribeAssetscannot be a field initialiser, since Dart will not let one field read another. Row layouts moved as a result, which costs only a test that named an offset. -
A prefab can declare the children it always spawns.
final barrel = EntityStruct.of(Barrel.new)on aParentspawns the barrel with the turret, links it underneath, and destroys it with the turret. Declarations nest, and a struct that declares itself is a registration error naming the ring. -
A prefab can move an inherited column's default.
DefaultPointercarriesdefaultValue, writable untilseal(), so two prefabs sharing a component no longer need a descriptor class and a hook that exists only to feedhas*calls. -
hasEntityfor a column holding an entity handle,hasEnumfor an enum-valued column, andhasEntity/optEntityonParamDescriptor. -
hasFloat32ArrayOfandhasFloat64ArrayOf, taking one default per element. Array defaults were a single scalar broadcast across every slot.
Fixed #
-
A system that throws no longer kills the game. It used to, silently and permanently (#126). One uncaught error anywhere on the game isolate stopped the tick for good, while
Game.isRunningwent on answeringtrueandstop()waited forever for a message from an isolate that no longer existed — a hung shutdown and a leaked pool, with no Dart error anywhere. The only trace was an engine log nothing in the app could see.Each listener is now guarded individually, so one bad system does not stop the others in the same tick, and the offending listener is disabled.
Debug and release differ here, and the difference is surprising enough to spell out. In debug an
assertfires and stops the game isolate — the loud answer, and no longer a silent one, becauseGame.startnow installs an error port: the death reaches the main isolate,isRunninggoes false, a pendingstop()completes with the error instead of hanging, and the failure is reported where Flutter and the test runner already look. In release there is no assert: the system stays disabled and the game keeps running. So the disable is release behaviour, andGame.enableSystem<MySystem>()brings it back if the throw was transient.Two things that were already true and are now written down. The tick is atomic as far as any reader is concerned: the fixed-tick dispatch runs before the tick is committed, so a tick that throws publishes nothing, and the next tick copies the last published state back over the partial write. A failed step is not retried, because the accumulator is debited before the step runs — otherwise a deterministically throwing system would be handed the same step forever.
Coroutines were already handled and are unchanged:
CoroutineSchedulerremoves a throwing coroutine and completes its handle with the error.GameListenergainsdisableAfterUncaught(), which is how a listener says whether it can be switched off.GameSystemdisables itself; the other three hosts do nothing, since switching off aGameState,SceneStructorEntityStructis not a smaller failure than the throw was. -
An entity's heap-object slots are freed when its row goes. A slot in the process-global table was the one thing a row owned that neither freeing the row nor dropping its page reclaimed, so a game using
hasHeapObjectand destroying entities grew that table for the life of the process. Bothdestroy()and scene unload release them now. -
System order honours every constraint.
compareTostates a partial order, whichList.sortis not defined for; given one it permuted the list and dropped an unrelated constraint elsewhere. A composer could sort ahead of the spawner, so an entity created during a tick was composed on the next one and published(0, 0)in between. -
Gamepads are detached before the input buffer is freed.
0.1.1 #
Documentation only. No code changes.
The README now opens with the column-and-row model and a code example, and
says plainly that a 2D game should depend on goo2d instead.
0.1.0 #
First published release. The dimension-agnostic kernel is real and tested:
- ECS —
Entity,Component,GameSystem,Query,GameEvent. - Storage — the native memory pool and ring buffers behind
dart:ffi, withDataDescriptorcomputing struct layouts at runtime. - Simulation — the fixed-tick loop, the scheduler, and
GameScene, run on their own isolate. - Hierarchy —
Child/Parentand composed world transforms. - Input, assets, coroutines and timelines, plus
GameViewfor the Flutter side. - Commands —
SinkCommand/SignalCommandover the shared record layer (ParamDescriptor,ParamPointer,ParamBatch,ParamBuffer), whichgood_netreuses instead of reimplements.
Not here yet: array-typed DataDescriptor fields in the codegen path,
dependency-based system ordering (compareTo is the mechanism today), and
audio playback — AudioClip decodes, but there is no backend or mixer. Web is
unsupported: the kernel needs dart:ffi and isolates.
0.0.1 #
- Initial split from
goo2d: dimension-agnostic ECS kernel, memory pool, ring buffer, scenes, fixed-tick loop, hierarchy, and generic asset registry. Never published.