Appearance
How to Build: Player Entity
The player is the one entity everything else revolves around. Enemies aggro toward it, the camera follows it, the heads-up display reflects its state, combat modifies its health, and the inventory screen reads its equipment. Getting the player entity right means every downstream system has a clean, predictable data source to work with.
In Koota Entity Component System, an entity is just an identifier. What makes it a "player" is the collection of traits we attach to it. I like to think of traits as labeled data slots -- each one holds exactly one concern, and systems query for the combinations they care about.
Defining the traits
Here's the full set of traits that make up the player entity. Each trait is focused on a single responsibility:
typescript
const IsPlayer = trait() // Tag -- no data, just marks this entity
const Transform = trait(() => ({
position: new THREE.Vector3(),
rotation: new THREE.Euler(),
quaternion: new THREE.Quaternion(),
}))
const Skills = trait({
attackLevel: 1, attackXp: 0,
strengthLevel: 1, strengthXp: 0,
defenceLevel: 1, defenceXp: 0,
hitpointsLevel: 10, hitpointsXp: 0,
})
const Health = trait({ current: 10, max: 10 })
const Gold = trait({ amount: 0 })
const Equipment = trait(() => ({
weapon: null as string | null, // gear identifier from shared data
armor: null as string | null, // gear identifier from shared data
}))
const Inventory = trait(() => ({
potions: 0,
ownedGear: [] as string[], // gear identifiers purchased but not equipped
}))
const ConsumableSettings = trait({
enabled: true,
hpThreshold: 0.4, // use potion when below 40% hit points
})
const Ref = trait(() => ({
object: null as THREE.Object3D | null,
}))Notice a few design decisions here. Gold is its own trait rather than being nested inside Inventory -- this makes it trivial for the combat loot system to call entity.set(Gold, ...) without touching inventory state. Equipment stores gear by string identifier, not by full object reference, so the entity stays serializable. ConsumableSettings is separate from Inventory because it's configuration (player preferences) rather than state (what you own).
The Ref trait bridges the Entity Component System world and the Three.js scene graph. It holds a reference to the Three.js Object3D that represents the player visually. Systems write to Transform, and a sync system copies those values to Ref.object for rendering.
Spawning the player
The spawn action creates the entity with all its traits initialized from the shared game data constants:
typescript
function spawnPlayer(world: World, position?: THREE.Vector3): EntityInside, it reads PLAYER.INITIAL_SKILLS from the shared data layer and uses those values to initialize Skills and Health. The position argument is optional -- if omitted, the player spawns at the origin.
The key insight is that this function should only be called once per game session. When the player dies or transitions between levels, we reposition the existing entity rather than destroying and recreating it. This preserves skills, gold, inventory, and equipment across level transitions.
Here's the pattern for level starts:
typescript
function spawnPlayerAtLevelStart(world: World) {
const player = world.queryFirst(IsPlayer)
if (player) {
// Reposition existing player
player.set(Transform, { position: new THREE.Vector3(startX, 0, startZ) })
} else {
// First load -- create the player
spawnPlayer(world, new THREE.Vector3(startX, 0, startZ))
}
}Rendering with React Three Fiber
The PlayerRenderer is a React component that queries the Entity Component System for the player entity and renders a 3D model at its position:
typescript
function PlayerRenderer(): JSX.ElementIt uses Koota's useQueryFirst hook to find the entity with IsPlayer, Transform, and Ref. On mount, it creates a Three.js mesh (start with a simple capsule or box -- replace with a glTF character model later) and stores the reference in the Ref trait so the transform sync system can update it.
The transform sync system
This is the bridge between Entity Component System data and the Three.js scene graph. Every frame, it copies position and rotation from the Transform trait to the actual Object3D:
typescript
function syncTransformToRef(world: World): voidIt queries all entities that have both Transform and Ref, then for each one, copies transform.position to ref.object.position and transform.quaternion to ref.object.quaternion. This pattern means movement systems, combat knockback, or any other logic only ever writes to the Transform trait -- they never touch Three.js objects directly. The sync system handles the rest.
File organization
src/core/traits/player.ts # All player traits defined above
src/core/actions/player.ts # spawnPlayer action
src/features/player/
├── PlayerRenderer.tsx # React Three Fiber component: model + transform sync
└── player-model.ts # Model loading (glTF or primitive placeholder)Keep traits in core/traits/ because multiple systems across the codebase query them. The renderer lives in features/player/ because it's specific to how the player looks and animates -- a concern that doesn't belong in the core Entity Component System layer.
What comes next
With the player entity in place, you have a foundation for everything in Phase 2 and beyond. The skill system reads and writes Skills. Movement writes to Transform. Combat reads Health and Equipment. The inventory user interface reads Gold, Inventory, and Equipment. Each of those systems can be built independently because they all interact through well-defined trait interfaces on this single entity.