The ECS that coordinates batching, effects, lighting, and tilemaps is now private to three-flatland. Upgrade existing projects by removing engine-ECS coupling, then update values and ownership at the public object boundary.
Flatland’s private renderer ECS grew from its earlier Koota integration. Koota’s typed traits, structure-of-arrays storage, and query model made the specialized design possible. Koota remains the recommended general-purpose ECS for application and gameplay state. This migration removes a package-level renderer dependency; it does not replace Koota.
Remove the Koota peer dependency
Section titled “Remove the Koota peer dependency”three-flatland no longer requires Koota. Remove koota from your application when it was installed only to satisfy the former peer dependency. Keep it when your game has its own Koota world or systems.
"dependencies": { "koota": "^0.6.5", "three": "^0.185.1"}Application state can still use any ECS. Store a Sprite2D reference or an application-owned ID in that world instead of storing a three-flatland entity handle.
Replace engine-ECS coupling
Section titled “Replace engine-ECS coupling”Search the application for these removed seams:
Flatland.worldandSpriteGroup.worldSprite2D.entity- effect-class
._traitand effect-instance._entity buildBatchQueryView(...)new BatchQueryView(world, ...)SpriteBatch.allocateSlot(...),freeSlot(...),swapSlots(...), andresetSlots(...)- React Three Fiber
<tileLayer>elements
These APIs have no public replacement. Their entity IDs, traits, schedules, and physical batch rows are implementation details, so a codemod cannot infer the application behavior that depended on them.
This part is intentionally less automatic: if an application reached into the old world, decide which application-owned state should carry that behavior now. TileLayer is owned and constructed by TileMap2D; remove direct <tileLayer> JSX and configure the containing tilemap instead.
Move rendering changes to the owning object:
sprite.position.set(x, y, 0)sprite.tint = [1, 0.4, 0.2]sprite.sortLayer = SortLayers.ENTITIESsprite.addEffect(glow)
group.add(sprite)group.remove(sprite)Use application-owned state for gameplay queries and identity. Use group.stats for sprite and batch counts. When tooling needs a read-only view of generated batches, read group.batches directly:
import { IsLitBatch } from 'three-flatland'
const litBatches = group.batches.where(IsLitBatch)Do not retain a SpriteBatch and modify its physical slots. Add, remove, and update the source sprites instead.
The codemod index lists migrations that have deterministic replacements. The private-ECS seam removal is intentionally a manual review.
Assign complete effect tuples
Section titled “Assign complete effect tuples”Effect vector getters return read-only tuple snapshots. Assign the complete tuple to publish through the effect’s live backing storage. Material effects update their attached sprite or tile projection, while light and pass effects update their live ECS/uniform state.
const Offset = createMaterialEffect({ name: 'offset', schema: { amount: [0, 0] as const }, node: ({ inputColor }) => inputColor,})
const offset = new Offset()
// This publishes both components.offset.amount = [4, 8]Replace component mutation such as offset.amount[0] = 4. Mutating a returned snapshot does not update the attached effect.
Fix values rejected by strict validation
Section titled “Fix values rejected by strict validation”Invalid configuration now throws before partial runtime state is published. Update values at their source instead of catching and continuing with a partially configured object.
Use a built-in sort layer or a finite signed 32-bit integer:
sprite.sortLayer = SortLayers.FOREGROUNDsprite.sortLayer = 12Values such as NaN, Infinity, fractions, and integers outside -2_147_483_648 through 2_147_483_647 are rejected.
When maxBatchSize is set, it must be a positive safe integer no greater than 1_048_576:
const group = new SpriteGroup({ maxBatchSize: 16_384 })Omit maxBatchSize to use the default 1_024 → 4_096 → 16_384 batch tiers.
Effect schema fields must be own data properties. Numeric defaults must be finite, and vector defaults must contain two to four finite numbers. Rename fields that collide with effect properties or methods, and avoid flattened-name collisions such as a tuple field named color beside a field named color_0.
const Pulse = createMaterialEffect({ name: 'pulse', schema: { strength: 0.5, origin: [0, 0] as const, }, node: ({ inputColor }) => inputColor,})Compute dynamic defaults before creating the effect class. Getters and setters on the schema object are not supported.
Give each effect instance one owner
Section titled “Give each effect instance one owner”A MaterialEffect instance belongs to one sprite or one tilemap at a time. Each owner can attach only one instance of a given material-effect class.
const glowA = new Glow()const glowB = new Glow()
spriteA.addEffect(glowA)spriteB.addEffect(glowB)To move an instance, detach it from the first owner before attaching it to the next:
spriteA.removeEffect(glowA)spriteB.addEffect(glowA)The same instance rule applies to LightEffect and PassEffect: a light or pass cannot be attached to two Flatland instances concurrently. Call setLighting(null) or removePass(effect) on the current owner before reattaching it. Create separate instances when both scenes need the effect at the same time.
Treat disposed tile objects as terminal
Section titled “Treat disposed tile objects as terminal”TileMap2D.dispose() and TileLayer.dispose() are terminal. Do not mutate, update, or reattach a disposed object. Construct a new TileMap2D when the map must return after disposal.
Changing TileMap2D.data or chunkSize rebuilds the tile projection and can replace its TileLayer and Sprite2DMaterial instances. Adding or removing an effect preserves each TileLayer but replaces its material and chunk projection. Standard three.js material state is copied to replacement materials, but material identity is not preserved.
Reacquire layers and materials after each rebuild:
tilemap.data = nextMapData
const walls = tilemap.getLayer('Walls')const wallsMaterial = tilemap.getLayerMaterial('Walls')
if (walls) walls.visible = trueif (wallsMaterial) wallsMaterial.opacity = 0.8Reacquire both layers and materials after changing data or chunkSize. After calling addEffect or removeEffect, the layer remains valid but its previously captured material does not; reacquire the material.
Pass capacity hints through the constructor
Section titled “Pass capacity hints through the constructor”const group = new SpriteGroup({ expectedSprites: 16_384 })const flatland = new Flatland({ expectedSprites: 16_384 })import { useMemo } from 'react'import { extend } from '@react-three/fiber/webgpu'import { Flatland, type FlatlandOptions } from 'three-flatland/react'
extend({ Flatland })
function World({ expectedSprites }: { expectedSprites: number }) { const args = useMemo( () => [{ expectedSprites }] satisfies [FlatlandOptions], [expectedSprites], )
return <flatland args={args}>{/* scene */}</flatland>}In React Three Fiber, keep the args tuple stable while the hint is unchanged. Changing the constructor arguments reconstructs the object. expectedSprites is intentionally not a mutable JSX property.
See Batch rendering for the runtime growth and reuse behavior.
Verify the migrated application
Section titled “Verify the migrated application”Run the application through its dynamic paths after the typecheck passes:
- Add enough sprites to cross the expected capacity and a batch boundary.
- Remove and re-add sprites, then remount the scene.
- Switch material, light, and pass effects repeatedly.
- Replace tile data and chunk size, then read layers and materials again.
- Dispose the scene and confirm no callback tries to reuse a terminal object.
The migration is complete when application code owns gameplay identity, all rendering changes go through public objects, and no removed ECS or slot symbol remains in source.
Related
Section titled “Related”SpriteGroupAPI referenceTileMap2DAPI reference- Batch demo — dynamic enrollment, removal, and batch counts
- Tilemap example — data and chunk-size projection rebuilds