@safe-engine/sdl
v1.3.3
Published
safe-engine with webgl renderer support base on sdl3 api
Readme
@safe-engine/sdl (SafeX SDL Engine)
High-performance WebGL & Native SDL3 Hybrid Engine Core for 2D/3D Casual Games
Maintained under SGM Unified Engineering Discipline
Documentation & Project Tracking
- 📋 Documentation Index & Sitemap
- 🗺️ Milestone v1.4 Plan & Tracking
- ✅ Active Task Checklist (Todo.md)
- 📈 v1.3.x Progress & Changelog
- 🏷️ Semantic Versioning Rules
- 🤝 GitHub Flow & PR Guidelines
- 🛠️ Monorepo Developer Setup
- 🇻🇳 Tài liệu Tiếng Việt
Install
npm install @safe-engine/sdlDev
bun run devResolution policy
Engine.start() accepts an optional fourth argument that controls how the
logical game size is presented inside the actual window or browser viewport:
Engine.start('My Game', 720, 1280, 'letterbox')Available policies are letterbox (default), overscan, stretch, and
integer-scale. The active Engine.viewport exposes the resulting rendered
rectangle, X/Y scale, and safe area.
Assets
Sprite and Label acquire cached native textures/fonts and release them when
their node is destroyed. Labels only rebuild their cached text texture when the
text or font changes.
import { AssetManager, TextureAtlas } from "./engine";
const group = AssetManager.createGroup()
.addTexture("player", "res/Texture/player.png")
.addFont("ui", "res/Font/LilitaOne-Regular.ttf", 24)
.addAtlas("buttons", "res/Texture/button.png", {
normal: { x: 0, y: 0, width: 220, height: 68 },
});
group.preload(({ progress }) => {
console.log(`Loading ${Math.round(progress * 100)}%`);
}).then(() => {
console.log("Assets ready");
const atlas = group.get<TextureAtlas>("buttons");
if (!atlas) throw new Error("buttons atlas was not preloaded");
sprite.setFrame(atlas, "normal");
const sheet = AssetManager.acquireSpriteSheet(
"res/Texture/player.png",
32,
32,
);
sprite.setFrame(sheet, "0");
group.unload();
sheet.release();
});Audio
The global Audio manager provides music and sound-effect groups, mute and
volume controls, looping, fades, and pooled sound voices. Use WAV files for
assets that must work on native and web builds; browsers may support additional
formats, but native playback currently uses SDL's WAV loader.
import { Audio } from "./engine";
// Reuses up to six simultaneous voices instead of allocating unbounded sounds.
const laser = Audio.createSound("res/Audio/laser.wav", {
group: "sfx",
maxVoices: 6,
volume: 0.8,
});
laser.play();
laser.play({ volume: 0.5 });
// Music loops by default. Fade times are measured in seconds.
Audio.playMusic("res/Audio/theme.wav", { fadeIn: 1.5 });
Audio.stopMusic(0.75);
Audio.group("master").volume = 0.9;
Audio.group("music").volume = 0.6;
Audio.group("sfx").muted = true;
Audio.group("music").fadeTo(0.25, 1);
const voice = Audio.play("res/Audio/impact.wav");
voice?.fadeOut(0.3);
laser.release();Audio.pause() and Audio.resume() control all playback without changing
individual pause state. The engine also suspends audio automatically while the
application is paused, backgrounded, or interrupted, and resumes only after
all active lifecycle conditions have cleared.
Scene lifecycle
When a scene becomes active, the engine calls onLoad() followed by onEnter().
Replacing it calls onExit(), onUnload(), and then destroys its node tree.
onPause() and onResume() follow app inactivity without unloading the scene.
onSaveProgress() is called automatically before the app enters the background
and again if the OS terminates it.
class GameScene extends Scene {
onSaveProgress(): void {
saveGame(this.progress);
}
onLowMemory(): void {
this.releaseOptionalCaches();
}
onUnload(): void {
this.releaseSceneResources();
}
onOrientationChange(
orientation: Orientation,
width: number,
height: number,
): void {
this.layout(orientation, width, height);
}
}Scenes can also implement onBackground(), onForeground(), and
onInterruption(active) for finer control over mobile transitions.
Player persistence
PersistenceJSON stores settings and level progress as a versioned JSON save.
It uses the browser's localStorage by default and accepts any object with the
same getItem, setItem, and removeItem interface.
import { PersistenceJSON } from "./engine";
interface PlayerSave {
settings: { musicVolume: number; soundVolume: number };
progress: { unlockedLevel: number; highScores: Record<string, number> };
}
const playerSave = new PersistenceJSON<PlayerSave>("player", {
version: 2,
defaults: () => ({
settings: { musicVolume: 1, soundVolume: 1 },
progress: { unlockedLevel: 1, highScores: {} },
}),
migrations: {
// Each migration upgrades version N to N + 1.
1: (old: any) => ({
...old,
progress: { ...old.progress, highScores: {} },
}),
},
});
const player = playerSave.load();
player.progress.unlockedLevel = 2;
playerSave.save(player);Loading an older save runs every migration in order and immediately rewrites the upgraded file. Invalid JSON, saves from newer app versions, and missing migrations throw rather than silently replacing player progress.
Resolution and safe areas
Engine.start() dimensions are the logical design resolution. Rendering keeps
that coordinate system at every window or device size, scales uniformly, and
letterboxes any remaining space. Browser rendering uses the device pixel ratio
for a sharp drawing buffer without changing game coordinates.
const { logicalWidth, logicalHeight, safeArea, safeInsets } = Engine.viewport;
// Place interactive UI inside the notch/system-bar-safe logical rectangle.
hud.node.setPosition(
safeArea.x + 24,
safeArea.y + 24,
);
// Convert raw window/client coordinates when integrating a platform API.
const world = Engine.screenToWorld(clientX, clientY);
const insideGame = Engine.viewport.containsScreenPoint(clientX, clientY);Scene touch callbacks already receive logical game coordinates. Use
Engine.worldToScreen() for overlays that live outside the SDL/WebGL renderer.
Input dispatch
Interactive components receive pointer input automatically. Button performs
sprite hit testing, captures a press until release, and invokes onClick
without scene-level touch forwarding.
const button = node.addComponent(Button);
button.onClick = () => startGame();
button.inputPriority = 10;Hit components are ordered by descending inputPriority. Equal priorities use
reverse render order, so the visually topmost component receives input first.
Calling event.stopPropagation() prevents lower-priority hit components and
the scene touch callback from receiving that event. Buttons do this by default;
set button.consumeInput = false to allow propagation.
UI
UI components use the existing node tree and logical coordinate system.
UIContainer supports horizontal and vertical stack layout and padding,
cross-axis alignment, flexible children, and anchor constraints. Panel,
UIImage, NineSlice, ProgressBar, TextInput, Toggle, and ScrollView
provide the standard retained widgets.
import {
Label,
Localization,
Node,
Panel,
ProgressBar,
TextInput,
Toggle,
UIContainer,
UIElement,
Widget,
} from "./engine";
Localization.add("en", { status: "Energy: {value}%" });
Localization.add("vi", { status: "Năng lượng: {value}%" });
Localization.use("en");
const hud = new Node("hud");
const panel = hud.addComponent(Panel);
panel.setSize(520, 180);
panel.direction = "vertical";
panel.padding = { top: 20, right: 20, bottom: 20, left: 20 };
const titleNode = panel.node!.addChild(new Node("title"));
titleNode.addComponent(UIElement).setSize(480, 48);
const title = titleNode.addComponent(Label);
title.setFont("res/Font/LilitaOne-Regular.ttf", 30);
title.setLocalized("status", { value: 75 });
title.wrapWidth = 480;
title.align = "center";
title.outlineWidth = 2;
const progressNode = panel.node!.addChild(new Node("progress"));
const progress = progressNode.addComponent(ProgressBar);
progress.setSize(480, 24).setValue(0.75);
const toggleNode = panel.node!.addChild(new Node("sound"));
const toggle = toggleNode.addComponent(Toggle);
toggle.setSize(72, 36);
toggle.onChange = (enabled) => console.log("Sound", enabled);
const nameNode = panel.node!.addChild(new Node("name"));
const nameInput = nameNode.addComponent(TextInput, {
placeholder: "Player name",
onChange: (value) => console.log("Name", value),
onSubmit: (value) => console.log("Submit", value),
});
nameInput.setSize(480, 48);
const topBar = new Node("top-bar");
topBar.width = 320;
topBar.height = 72;
topBar.addComponent(Widget, { top: 16, left: 16, right: 16 });
// Disable a subtree without removing it from the hierarchy.
topBar.active = false;Widget pins a node to the current safe-area border by default, so HUD
controls stay inside phone notches/system bars and resize with any window size.
Set inSafeArea: false to pin it against the full device window instead.
Supplying both sides on an axis stretches the node on that axis; supplying one
side keeps the node's existing size and pins it to that border. Set
centerHorizon or centerVertical to center the node on that axis.
Set node.active = false to pause updates, rendering, input, and descendant
traversal for that node's whole subtree until it is re-enabled.
Anchor values are normalized to the parent UI element. Equal minimum and maximum anchors pin an element to a point; different values stretch it between two points. Offsets then inset or move the anchored rectangle.
const child = new Node("full-size-child");
panel.node!.addChild(child);
const element = child.addComponent(UIElement);
element.setAnchors(0, 0, 1, 1);
element.offsetLeft = 16;
element.offsetTop = 16;
element.offsetRight = 16;
element.offsetBottom = 16;ScrollView clips its descendants and uses its first child as the movable
content root. Set contentWidth and contentHeight to define scroll limits.
Labels support explicit newlines, word wrapping, horizontal and vertical
alignment, color, opacity, outlines, and localization keys.
TextInput focuses on pointer press, opens the platform text entry path, and
emits onChange, onFocus, onBlur, and onSubmit callbacks.
Tweening
Tweens use seconds and advance with engine time, so they pause automatically when the app is paused or backgrounded. Numeric properties and nested colors can be animated directly.
import { Easing, Tween } from "./engine";
Tween.to(
player.node,
{ x: 600, y: 240, rotation: 360, scaleX: 1.5, scaleY: 1.5 },
0.8,
{
ease: Easing.cubicOut,
delay: 0.1,
onComplete: () => console.log("move complete"),
},
);
Tween.sequence()
.to(sprite, {
opacity: 0.25,
color: { r: 255, g: 96, b: 64 },
}, 0.2, { ease: Easing.quadOut })
.delay(0.15)
.call(() => console.log("flash"))
.to(sprite, {
opacity: 1,
color: { r: 255, g: 255, b: 255 },
}, 0.3)
.start();Available callbacks are onStart, onUpdate, onComplete, and onStop.
Calling stop() on a tween or sequence cancels it. Scene changes cancel all
active tweens.
