The knobs
How a game feels is a few dozen numbers, and this page says where each of them is. All of it is data on const classes, so changing one is a recompile and never a schema change, and none of it needs a renderer to test.
Units are metres, seconds and radians throughout. A speed is metres per second, a rate is per second, an angle is radians, and a chance is 0 to 1. There is no scale factor anywhere: a corridor two metres wide is 2.0, and a body that walks at 6.0 crosses it in a third of a second.
Where the numbers are #
| What you want to change | Class | Package |
|---|---|---|
| Walking, gravity, jump height | MovementSettings |
flutter3d_physics |
| Double jumps, dashes, wall slides, ground pounds | RunnerSettings |
flutter3d_game_platformer |
| Weapons | WeaponDef |
flutter3d_game_shooter |
| Monsters | MonsterDef, gathered into a Bestiary |
flutter3d_game_shooter |
| How a car drives | VehicleSettings, TireSet |
flutter3d_game_racing |
| How hard the AI drivers are | AiSettings |
flutter3d_game_racing |
| Where the camera sits | RigSettings, FollowSettings, ChaseSettings |
flutter3d_game, the two genres |
| Shadows, bloom, fog, exposure, colour | ShadowSettings and the rest of RenderSettings |
flutter3d |
| How much time a frame may give to work that can wait | RenderSettings.frameWorkBudget |
flutter3d |
| What frame time the picture is held to, and how eagerly it recovers | AdaptiveQualitySettings |
flutter3d |
| How far back a kill camera or a rewind reaches | RewindBuffer.history |
flutter3d_sim |
| How big a hole a rocket leaves | Breaches.blast(width:, height:, depth:) |
flutter3d_sim |
| How much a wall muffles a sound | SoundOcclusion(perObstacle:, floor:) |
flutter3d_game |
| How far the automap reveals around the player | Automap(revealRadius:) |
flutter3d_sim |
| How coarse the visibility table is | bake_visibility --cell |
flutter3d_sim |
| How fine, how many bounces and how many rays a lightmap gets | flutter3d lightmap --density --bounces --samples |
flutter3d_build |
| How far an enemy jumps | JumpReach.of(tuning) |
flutter3d_sim |
Movement #
MovementSettings is what every body in every genre stands on, player and monster alike.
const MovementSettings(
walkSpeed: 6.0, // sprintSpeed is 10.0
groundAcceleration: 70.0, // how fast it reaches that speed
groundFriction: 55.0, // and how fast it stops
airAcceleration: 14.0, // control in the air, deliberately much lower
gravity: 24.0, // 9.81 feels like a documentary
jumpSpeed: 8.0, // straight up, meters per second
stepHeight: 0.4, // a kerb it walks over instead of into
coyote: Duration(milliseconds: 100), // a jump still counts this long after a ledge
jumpBuffer: Duration(milliseconds: 100), // and this long before landing
);
The two forgiveness windows at the end are the ones a player feels and cannot name. Set both to Duration.zero and the game plays as if it were fighting you, with nothing on screen having changed.
CharacterController.settings is not final. That is the whole of what ice, mud, water, a low-gravity room and a slow-effect are made of: assign a different constant and the body changes on the next step, with no state to migrate and nothing to unwind when the effect ends.
What a platformer adds on top #
RunnerSettings is the second layer, and it is large because a platformer is a verb list. A sample of it, with the shipped defaults:
jumpSpeed |
9.5 | Higher than the base body's 8.0 |
airJumps, airJumpSpeed |
1, 8.2 | The double jump, weaker than the first |
jumpCut |
0.45 | Releasing the button keeps this fraction of the rise, which is what makes a tap a small hop |
coyote, jumpBuffer |
120 ms, 120 ms | Slightly more forgiving than the base body |
dashSpeed, dashCooldown |
18.0, 550 ms | |
wallSlideSpeed, wallJumpUp, wallJumpPush |
3.2, 9.0, 7.5 | Falling speed against a wall, and the two halves of the jump off it |
stompBounce, stompBounceHeld |
7.5, 11.0 | Landing on something, and landing on it holding jump |
mantleLow, mantleHigh, mantleReach |
0.35, 1.5, 0.45 | The band of ledge heights it will pull itself onto |
Weapons and monsters #
A weapon is a WeaponDef, and a game's arsenal is a list of them. The five it insists on are name, behaviour, ammo, damage and shotsPerSecond; everything else has a default that means "the simple case".
The shipped shotgun, from flutter3d_game_shooter's sample arsenal:
import 'package:flutter3d_game_shooter/flutter3d_game_shooter.dart';
const WeaponDef(
name: 'Shotgun',
behavior: HitscanBehavior(), // or MeleeBehavior, or ProjectileBehavior
ammo: AmmoType.shells,
damage: 11.0, // per ray
shotsPerSecond: 1.4,
rayCount: 8, // what makes it a shotgun
spread: 0.10, // radians
range: 60.0,
falloffStart: 6.0, // full damage to here
falloffEnd: 26.0, // and a floor of minimumDamageFraction past here
minimumDamageFraction: 0.2,
knockback: 2.0,
recoil: 0.05,
recoilRecovery: 4.5,
loudness: 28.0,
);
loudness is the one to know about: firing is a noise with a radius in metres, and it is what wakes the room next door. A quiet weapon is a design decision, not an audio setting.
AmmoType.none exists so a starting weapon can never run out. A player stranded with nothing to fire is a player reloading a save.
A monster is a MonsterDef: health, speed, attack, radius, height, and then sightRange (26 m), turnRate (6.0 rad/s), painChance (1.0) and painCooldownSeconds (0.2). The last two are what make a heavy monster feel heavy: a painChance under one means some hits do not interrupt it, and it keeps walking at you.
The catalogue is handed to Bestiary rather than reached for, so a second shooter brings its own and inherits none of this one's.
Cars #
VehicleSettings is a car, and TireSet is what it is standing on. The engine numbers are the obvious half:
maxSpeed, maxReverse |
52.0, 12.0 | m/s: about 187 km/h |
enginePush, brakeStrength |
14.0, 26.0 | Stopping is harder than going, as it is in a real car |
maxSteer, steerFalloff |
0.62, 26.0 | Radians of lock, and how much of it is taken away by speed |
wheelBase |
2.7 | Metres between axles; what decides the turning circle |
rollingResistance, holdSpeed, holdSlope |
0.9, 0.7, 1.2 | Why a parked car stays parked |
Those last three are a repaired bug rather than a feature: the circuit's starting grid sits on a one-in-fifty rise, and a car with no rolling resistance and no static hold rolled backwards off it, gaining speed, from the moment the level loaded. holdSlope is what stops the fix becoming a handbrake that is always on.
Grip lives in TireSet instead, and TireSet.road is the shipped set. That split is deliberate: a wet race is the same car with different tyres.
Difficulty, as a number #
AiSettings.skill is 1.0, and it scales the rest. Under it sit the numbers that decide how a driver is fast: brakeHorizon (45 m of lookahead for a corner), corneringGrip (14.0, how much the driver believes the car has), lookAheadPerSpeed (0.55) and rubberBandClamp (0.22, the ceiling on catching up).
Rubber banding is capped at 22% on purpose. A rubber band with no ceiling turns every race into the same race, and players notice within two laps. What they notice is less "the AI is cheating" than "nothing I do matters".
The picture #
RenderSettings holds the look, and most of it is off by default because most of it costs. exposure is 1.6 and tonemap is on; bloom, fog, look (contrast, saturation, temperature, vignette, grain) and sky are each their own settings object. reflections and ambientOcclusion both default to enabled: false.
Everything 0.8.0 added is off as well, and each one says what switching it on costs:
| Setting | Default | What it costs when on |
|---|---|---|
antiAlias.temporal (TemporalSettings) |
enabled: false |
Two velocity passes, a draw per moved node, two output-sized history targets. Turns MSAA off |
TemporalSettings.clip |
TemporalClip.aabb |
kdop8, kdop16, kdop32: more arithmetic per pixel in the resolve |
TemporalSettings.reactive |
0 | One more pass over the blended draws and contributors |
motionBlur (MotionBlurSettings) |
enabled: false, shutter 0.5, radius 20 px |
Two tile passes and a fifteen-sample gather; fills the velocity buffer |
spatialUpscale (SpatialUpscaleSettings) |
enabled: false, sharpen 0.2 |
A twelve-tap pass at output size. Only below a renderScale of one, with the temporal resolve off |
localExposure (LocalExposureSettings) |
enabled: false, strength 0.7, 2 stops each way |
Three small passes at an eighth of the frame |
outputTransform |
OutputTransform.sdr |
extendedSrgb: a float frame, on a device with an HDR output format (WebGPU on an HDR display); the SDR frame elsewhere |
energyCompensation |
false | Arithmetic in the metal-rough stages |
diffuseModel |
DiffuseModel.lambert |
eon: arithmetic in the metal-rough stages |
clusteredLights |
false | A 16 × 9 × 24 light grid built on the CPU per view |
volumetricFog (VolumetricFogSettings) |
enabled: false, density 0.02, 24 steps, 40 m |
A half-resolution march with a shadow lookup at every step, and an upsample |
ambientOcclusion.method |
AmbientOcclusionMethod.ssao |
gtao, ssil: a third scene attachment, the albedo buffer |
shadows.filter |
null (follows directionalLightRadius) |
evsm: an rgba32f atlas and two blur passes; needs float filtering |
transparency |
TransparencyMode.sorted |
weightedBlended: two scene-sized targets, a kept depth buffer, no MSAA |
occlusion |
OcclusionMode.none |
software: a 256 × 128 CPU raster of the marked occluders. hiZ: a depth pyramid and a readback, and no MSAA |
frameWorkBudget |
Duration.zero (no limit) |
Nothing; it only makes deferrable work wait |
aliasTargets |
false | Nothing; fewer pooled targets |
AdaptiveQuality sits outside RenderSettings and rewrites it each frame. AdaptiveQualitySettings.budget is 16 667 µs, sixty frames a second; a row is kept while it fits under headroom (0.9) of that and climbed to only after it has fitted under climbHeadroom (0.75) for climbFrames (30) frames running. The rows come from QualityTable.of(deviceClass), and the costs in the tables committed today are software-rasteriser stand-ins until each device class is measured. The frame has the rest.
ShadowSettings is where the two expensive numbers are:
resolution(1024) is the directional light's map, split intocascades(3) tiles.cubeResolution(512) is the atlas point and spot lights share, six faces across and one row per shadowed light.
Those two are separate settings and used to be one. Sizing the cube atlas from the sun's number put 402 MB of texture on the GPU and made the racing game unplayable in a browser for months. Raise resolution for crisper sun shadows; raise cubeResolution only when you can see the stair-stepping, and know that it costs six faces times the number of lit rooms.
Changing them honestly #
Every class on this page is reachable from a plain test() with no window and no GPU, because the packages they live in import no renderer. That is the difference between tuning and guessing: a jump height that has to clear a specific ledge, an AI lap that has to come in under a time, a car that has to still be on the grid after ten seconds. Each is a test that runs in milliseconds and says what happened, where a playthrough shows you a frame and leaves you to judge it.
Testing is how this repository does that, and the racing package's test/parked_test.dart is a short worked example of a feel bug caught that way.