docs / getting-started / your-first-game

Your First Game

Walk through what create-g2w scaffolds: a 640×360 scene where you steer a square with WASD or the arrow keys — small enough to read in one sitting, real enough to show every boundary in the framework.

1 · The canvas

index.html holds one canvas at the game's logical resolution plus a hidden <pre id="error"> box. The template's stylesheet caps the canvas with max-width: 100%, so narrower viewports scale the game proportionally instead of overflowing:

index.html
<canvas id="game" width=640 height=360></canvas>
<pre id="error" hidden></pre>

2 · Wire the adapter boundary

In src/main.ts, create the renderer and input adapters once, at the application boundary. Game logic never touches DOM events — it reads input only through the DOM-free InputState:

src/main.ts
const renderer = new PixiRenderer();
const input = new InputState();
const domInput = new DomInput(input, {
  canvas,
  keyTarget: window,
  blurTarget: window,
  // pointer + key coords in the game's logical space
  logicalSize: { width: VIEW_WIDTH, height: VIEW_HEIGHT },
});

3 · Compose G2WGame

G2WGame owns the application lifecycle: it drives the renderer, the frame loop, and disposal. The frame path is a plain synchronous update — no Effect work, promises, or allocations per frame. One rule to remember:input.endFrame() runs exactly once per frame, after every consumer has read this frame's state.

src/main.ts
const game = new G2WGame({
  renderer,
  canvas,
  backgroundColor: "#1099bb",
  width: VIEW_WIDTH,
  height: VIEW_HEIGHT,
  resolution: window.devicePixelRatio || 1,
  onUpdate: (deltaTime) => {
    scenes.update(deltaTime);
    // pressed/released edges reset here, after every
    // consumer has read this frame's state
    input.endFrame();
  },
});
await game.init();

4 · Keep game logic renderer-agnostic

The player movement lives in src/scenes/play-scene.ts — a pure function over a structural input interface. Because it imports nothing but types, the template's Vitest suite drives it deterministically without a browser, PixiJS, or real timers:

src/scenes/play-scene.ts
export function nextPlayerPosition(
  input: MoveInput,       // structural: { isKeyDown(code) }
  position: Position,
  deltaTime: number,      // clamped, in seconds
  bounds: Bounds,
): Position {
  // WASD / arrows → velocity × deltaTime, clamped
}

5 · Register the scene and its cleanup

Core scenes are renderer-agnostic: PixiJS display objects stay inmain.ts at the adapter boundary, added on enter and removed on exit. Scene disposal joins the game's LIFO finalizer chain, so shutdown releases scenes before the renderer:

src/main.ts
const playScene: Scene = {
  init() {},
  enter() { renderer.stage.addChild(square); },
  update(deltaTime) {
    const next = nextPlayerPosition(input, player, deltaTime, bounds);
    player.x = next.x; player.y = next.y;
    square.x = next.x; square.y = next.y;
  },
  exit() { renderer.stage.removeChild(square); },
  dispose() { square.destroy(); },
};

const scenes = new SceneManager({ scenes: { play: playScene }, initial: "play" });
// scene disposal joins the game's LIFO finalizer chain:
// shutdown releases scenes before the renderer
game.addFinalizer(() => scenes.dispose());

6 · Boot it

Attach the DOM adapter, start the scene manager, then start the frame loop. The template also registers a pagehide handler that disposes the adapter first, then the game — G2WGame disposal is idempotent, so a repeated pagehide is safe:

src/main.ts
domInput.attach();
scenes.start();
game.start();

Run pnpm dev to open the game in a browser,pnpm test for the deterministic unit tests, andpnpm typecheck for strict TypeScript — the scaffolded project has all three wired up.

Where to go next

Swap the square for Sprite2D + Transform2D models with createSpriteView (Sprites & Transforms), load textures through the AssetManager (Assets), add sound with @g2w/audio-web, or sample gamepads with@g2w/input-gamepad. Thearchitecture notes explain the ownership rules behind each boundary.