Files
2nd/10_Wiki/Topics/Coding/Game_Loop_ECS.md
T
2026-05-09 21:08:02 +09:00

7.8 KiB

id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
id title category status source_trust_level verification_status created_at updated_at tags tech_stack applied_in aliases
game-loop-ecs Game Loop / ECS — 매 frame / Entity-Component-System Coding draft B conceptual 2026-05-09 2026-05-09
game
ecs
architecture
vibe-coding
language applicable_to
TS / Various
Game
Frontend
game loop
fixed timestep
ECS
entity-component-system
bevy
bitECS

Game Loop / ECS

Game = 매 frame update + render. Fixed timestep + interpolation. 큰 game = ECS (Entity-Component-System). data-oriented + cache-friendly.

📖 핵심 개념

  • Frame: 16ms (60fps).
  • Fixed timestep: physics 결정성.
  • ECS: Entity (id) + Component (data) + System (logic).
  • Data-oriented: cache locality.

💻 코드 패턴

기본 game loop

let last = performance.now();
function frame(now: number) {
  const dt = (now - last) / 1000;
  last = now;
  
  update(dt);
  render();
  
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

dt 가 frame 차이. 30fps 든 120fps 든 같은 속도.

Fixed timestep (physics)

const FIXED = 1 / 60;
let acc = 0;
let last = performance.now();

function frame(now: number) {
  const dt = (now - last) / 1000;
  last = now;
  acc += dt;
  
  while (acc >= FIXED) {
    fixedUpdate(FIXED);  // physics — 항상 같은 step
    acc -= FIXED;
  }
  
  const alpha = acc / FIXED;  // interpolation
  render(alpha);
  
  requestAnimationFrame(frame);
}

→ "Fix Your Timestep!" 패턴. multiplayer / replay 일관.

단순 entity 직접

class Player {
  x = 0; y = 0; vx = 0; vy = 0;
  hp = 100;
  
  update(dt: number) {
    this.x += this.vx * dt;
    this.y += this.vy * dt;
  }
  
  render(ctx: CanvasRenderingContext2D) {
    ctx.fillRect(this.x, this.y, 32, 32);
  }
}

const player = new Player();
const enemies = [...]; // Enemy[]

function update(dt: number) {
  player.update(dt);
  for (const e of enemies) e.update(dt);
}

→ 작은 게임 OK. 100+ entity = ECS.

ECS — bitECS (TS, fast)

import { createWorld, addEntity, addComponent, defineComponent, defineQuery, defineSystem, Types, pipe } from 'bitecs';

const world = createWorld();

const Position = defineComponent({ x: Types.f32, y: Types.f32 });
const Velocity = defineComponent({ vx: Types.f32, vy: Types.f32 });
const Sprite = defineComponent({ texture: Types.ui8 });

// Entity 생성
const player = addEntity(world);
addComponent(world, Position, player);
addComponent(world, Velocity, player);
Position.x[player] = 100;
Position.y[player] = 100;
Velocity.vx[player] = 50;

// System — Position + Velocity 둘 다 가진 entity
const moveQuery = defineQuery([Position, Velocity]);
const moveSystem = defineSystem((world) => {
  const ents = moveQuery(world);
  for (const eid of ents) {
    Position.x[eid] += Velocity.vx[eid] * world.dt;
    Position.y[eid] += Velocity.vy[eid] * world.dt;
  }
});

const renderQuery = defineQuery([Position, Sprite]);
const renderSystem = defineSystem((world) => {
  for (const eid of renderQuery(world)) {
    drawSprite(Sprite.texture[eid], Position.x[eid], Position.y[eid]);
  }
});

const pipeline = pipe(moveSystem, renderSystem);

function frame(dt: number) {
  world.dt = dt;
  pipeline(world);
}

→ Component = SoA (Struct of Arrays). Cache-friendly.

Why ECS?

OOP class:
class Enemy { x, y, hp, ... }
- 상속 hierarchy 의 함정 (Enemy extends Character)
- 같은 class 의 instance = memory 분산

ECS:
Entity = id, Component = data only
- 어떤 component 조합도 가능
- Cache-friendly iteration
- System = pure function on data

Bevy (Rust ECS, web 가능)

use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .add_systems(Update, (movement, collision))
        .run();
}

#[derive(Component)]
struct Velocity(Vec2);

fn movement(mut q: Query<(&mut Transform, &Velocity)>, time: Res<Time>) {
    for (mut t, v) in &mut q {
        t.translation += v.0.extend(0.0) * time.delta_seconds();
    }
}

→ WASM 빌드 → web game.

import Phaser from 'phaser';

class Scene extends Phaser.Scene {
  preload() {
    this.load.image('player', 'player.png');
  }
  
  create() {
    this.player = this.physics.add.sprite(100, 100, 'player');
    this.cursors = this.input.keyboard.createCursorKeys();
  }
  
  update() {
    if (this.cursors.left.isDown) this.player.setVelocityX(-200);
    else if (this.cursors.right.isDown) this.player.setVelocityX(200);
    else this.player.setVelocityX(0);
  }
}

const game = new Phaser.Game({
  type: Phaser.AUTO,
  width: 800, height: 600,
  scene: Scene,
  physics: { default: 'arcade' },
});

PixiJS (rendering, 강력 2D)

import { Application, Sprite, Assets } from 'pixi.js';

const app = new Application();
await app.init({ resizeTo: window });
document.body.appendChild(app.canvas);

const tex = await Assets.load('player.png');
const sprite = new Sprite(tex);
app.stage.addChild(sprite);

app.ticker.add((time) => {
  sprite.x += time.deltaTime;
});

Collision (AABB)

function aabb(a: Rect, b: Rect): boolean {
  return a.x < b.x + b.w &&
         a.x + a.w > b.x &&
         a.y < b.y + b.h &&
         a.y + a.h > b.y;
}

// Quadtree 큰 게임
import RBush from 'rbush';
const tree = new RBush();
tree.load(items);
const collisions = tree.search(playerBox);

Audio

import { Howl } from 'howler';

const sfx = new Howl({ src: ['shoot.mp3'], volume: 0.5 });
sfx.play();

const music = new Howl({ src: ['bgm.mp3'], loop: true, volume: 0.3 });
music.play();

Input

const keys = new Set<string>();
window.addEventListener('keydown', e => keys.add(e.key));
window.addEventListener('keyup', e => keys.delete(e.key));

// 또는 gamepad
const gp = navigator.getGamepads()[0];
if (gp?.buttons[0].pressed) jump();

Networking (multiplayer)

// Authoritative server + client prediction + reconciliation
const ws = new WebSocket('wss://server/game');

let serverState = ...;
let predictedState = ...;
let inputs: Input[] = [];

function frame(dt: number) {
  const input = readInput();
  inputs.push(input);
  predictedState = applyInput(predictedState, input, dt);
  ws.send(JSON.stringify(input));
  render(predictedState);
}

ws.onmessage = (msg) => {
  const ack = JSON.parse(msg.data);
  // Reconcile: server state + 이후 inputs 재적용
  serverState = ack.state;
  predictedState = serverState;
  for (const i of inputs.slice(ack.lastInput + 1)) {
    predictedState = applyInput(predictedState, i, FIXED);
  }
};

Skia (canvas / native 2D, fast)

Flutter / RN Skia / 자체 — 모바일 게임 / 차트.
@shopify/react-native-skia.

60fps budget

Frame:    16.6ms
- Input:    ~1ms
- Update:   ~5ms
- Physics:  ~3ms
- Render:   ~5ms
- Idle:     ~2ms

→ 어떤 system 가 budget 초과?

🤔 의사결정 기준

게임 종류 추천
작은 / casual 직접 + Canvas / DOM
큰 2D Phaser / PixiJS
큰 game / multiplayer ECS (bitECS / Bevy)
3D Three.js / R3F / Bevy / Unity
Mobile native Unity / Unreal / Godot
Cross-platform native Bevy WASM + native target

안티패턴

  • setInterval for game loop: drift / pause 문제. requestAnimationFrame.
  • No fixed timestep + physics: 다른 fps = 다른 결과.
  • OOP 깊은 hierarchy: 변경 어려움. ECS or composition.
  • Render in update: 분리.
  • DOM nodes 큰 game: Canvas / WebGL.
  • Memory allocation 매 frame: GC pause. pool / reuse.
  • Audio 재생 매번 새 instance: pool.

🤖 LLM 활용 힌트

  • Fixed timestep + interpolation 표준.
  • ECS = 큰 게임의 답.
  • Phaser / Pixi 가 빠른 시작.
  • 60fps budget 기반 측정.

🔗 관련 문서