simuu/lib

Some shared primitives for making games.

Time control

Pause, timescale, and frame stepping for game loops.

const time = createTimeControl({
	scales: [0.25, 0.5, 1, 2, 4],
	initialScale: 1,
})

// game loop
function update(rawDelta) {
	const dt = time.delta(rawDelta)
	if (dt === 0) return // paused or waiting for step
	// update game with dt
}

// controls
time.toggle() // pause/unpause
time.faster() // next timescale
time.slower() // previous timescale
time.step() // advance one frame (enters step mode)
time.play() // exit step mode, resume

State: time.scale, time.paused, time.stepping

Camera

2D camera with coordinate conversion, zoom, bounds, and target following.

import { Camera } from '../lib'
const cam = new Camera(800, 600)
cam.setBounds(0, 0, worldWidth, worldHeight)

cam.centerOn(player.x, player.y)
cam.update(deltaMs)
cam.applyTo(pixiContainer)

const { x, y } = cam.screenToWorld(mouseX, mouseY)

Smooth follow with lerp:

cam.startFollow(player, 0.1)

Zoom toward cursor:

canvas.addEventListener('wheel', (e) => {
	const world = cam.screenToWorld(e.clientX, e.clientY)
	cam.zoomAt(e.deltaY > 0 ? -0.1 : 0.1, world.x, world.y)
})

Effects

Tree-structured effects for ability timing. Parent fires on schedule, children fire with it (optionally gated by conditions). Keeps ability logic declarative instead of scattered setTimeout calls.

const attack = createEffect(
	id,
	archer.id,
	target.id,
	{ delay: 0.5, interval: 1, repeat: Infinity },
	{
		payload: { type: 'damage', value: 25 },
		children: [
			{
				payload: { type: 'heal', value: 5 }, // lifesteal
				condition: { every: 3 }, // every 3rd hit
			},
		],
	},
)

// game loop
for (const effect of activeEffects) {
	for (const fire of tickEffect(effect, dt)) {
		applyPayload(fire.payload, fire.target)
	}
}

Payloads: damage, heal, construct, pulse

Conditions: { always: true }, { every: N }, { chance: 0-1 }

Combat log

Event types and query utilities. Storage lives in game layer.

import { byType, bySourceOwner, sumValues, entityRef } from '../lib'

log({
	t: 5.2,
	e: 'SPELL_DAMAGE',
	s: entityRef(unitId, 1, 'archer'),
	d: entityRef(targetId, 2, 'grunt'),
	v: 25,
})

const myDamage = sumValues(
	events.filter(byType('SPELL_DAMAGE')).filter(bySourceOwner(playerId)),
)

Events: SPELL_DAMAGE, SPELL_HEAL, SPELL_CAST_START, SPELL_CAST_SUCCESS, SPELL_CAST_FAILED, SPELL_SUMMON, UNIT_DIED, GAME_START, GAME_END, RESOURCE_SPEND, RESOURCE_GAIN

Filters: byType, bySourceOwner, byDestOwner, byOwner, bySourceType, byDestType, byAbility, byTimeRange, byEntityId

Aggregations: sumValues, totalDamageByOwner, totalHealingByOwner, killsByOwner, deathsByOwner, spawnsByOwner, spentByOwner, getLifespan, avgLifespanByUnitType

Utils

clamp(x, 0, 100)
lerp(a, b, 0.5)
naturalize(100, 0.1) // 100 ± 10%
inverseLerp(0, 100, 50) // 0.5
remap(50, 0, 100, 0, 1)

pick(['a', 'b', 'c'])
shuffle(array)
sample(array, 3)
random(10, 20)
randomInt(1, 6)

formatDuration(125) // "2:05"
formatDurationPrecise(125.7)
formatTime(timestamp)
timeSince(timestamp) // "5 minutes ago"

throttle(fn, 100)
debounce(fn, 200)

Also: roundOne, toPercent, range, remove, distance, distanceSq

Word generator

codename() // "golden-falcon"
word('predicates') // "swift"
word('objects') // "meadow"
word('collections') // "fleet"
word('teams') // "pride"

Sounds

Sound system with pooled samples, file samples, and procedural generation. Built on @pixi/sound.

import { initSoundRegistry, playSound, type SoundRegistry } from 'simuu/sounds'

const registry: SoundRegistry = {
	pools: [
		{
			name: 'click',
			paths: Array.from({ length: 11 }, (_, i) => `/sfx/ui/button-${i + 1}.webm`),
			volume: 0.3,
		},
	],
	files: [
		{ name: 'victory', url: '/sfx/victory.webm', volume: 0.7 },
	],
	generated: [
		{ name: 'beep', buffer: generateBeep(), volume: 0.4 },
	],
	defs: {
		click: { volume: 0.3, throttleMs: 50 },
		victory: { volume: 0.7 },
		beep: { volume: 0.4 },
	},
}

await initSoundRegistry(registry)
playSound('click', registry) // picks random from pool
playSound('victory', registry)

For procedural audio, use createWavBuffer:

import { createWavBuffer } from 'simuu/sounds'

function generateBeep(): ArrayBuffer {
	const { samples, writeSample, buffer } = createWavBuffer(0.1)
	for (let i = 0; i < samples; i++) {
		const t = i / 44100
		writeSample(i, Math.sin(2 * Math.PI * 440 * t) * 0.5)
	}
	return buffer
}

Global controls:

import { soundConfig, toggleSound, setMasterVolume } from 'simuu/sounds'

toggleSound() // on/off
setMasterVolume(0.5)
soundConfig.enabled // current state