2B2D is a simple, 2D-only game engine heavily inspired by Bevy, written in Typescript, rendered in WebGPU, with no dependencies. It's meant for small, simple games.
Some features of the engine:
- Purely ECS-driven.
- Works (somewhat) well with LDtk and Aseprite.
- Written in TypeScript and runs in browsers with WebGPU support.
- Includes basic supports for gamepads / input mapping.
- Has a limited, rudimentary AABB-based physics system—just about enough to support a Mario-style platformer game.
- Self-contained--no runtime dependencies.
- MIT Licensed.
Included in this repo is an example game (in the Example
folder).
You can also play on Itch.io.
If you'd like to develop your own game in 2B2D:
- Fork this repository.
- Delete the
src/Example
folder. - Create your new game in whatever folder you like and update
src/index.ts
to point to it. - Install dependencies:
yarn install
- Develop:
yarn dev
- When you're ready to publish,
yarn build
and deploy thedist
folder to your host.
P.S. If you're forking this repo to make your own game, consider replacing the README to talk about your game instead of 2B2D, just for clarity's sake.
Plugins - Games are generally organized into plugins. Plugins are really just functions that schedule systems, handle signals, and often include other plugins. For example:
// An initialization plugin
export default function InitPlugin(builder: Builder) {
builder.schedule.enter(InitState, LoadAssets);
builder.schedule.update(InitState, AwaitLoaded);
}
This plugin tells the engine to execute the LoadAssets
system when the InitState
state is entered. Then, the engine will execute the AwaitLoaded
system every frame until the InitState
state exits.
Systems - Systems are just functions that can be scheduled. Some systems run every frame of a state, some only run one frame (when the state enters or exits), and some respond to signals. For example:
const logo = 'logo' as Handle;
export default function LoadAssets(update: Update) {
const assets = update.assets();
assets.add(TextureAsset.loadSingleSprite(logo, 'assets/logo.png'));
}
export default function AwaitLoaded(update: Update) {
const assets = update.assets();
const isLoaded = assets.loaded([logo]);
if (isLoaded) {
update.schedule.exit(InitState);
update.signals.send(new LoadedSignal('InitPlugin'));
}
}
The LoadAssets
system begins to load the logo texture. It executes one frame when the InitState
enters.
The AwaitLoaded
system checks to see if the logo is loaded every frame during the InitState
state (except the enter and exit frames). Once the texture is loaded, it exits the InitState
and sends a LoadedSignal
signal.
Fixed Systems - Fixed Systems are regular systems that are restricted to run on a (somewhat) fixed framerate (60fps by default). You can override the default fps. All fixed systems run at the same fps and will all run in the same frame(s). For example:
// Override the default fixed timestep of 60fps to 30fps
builder.setFixedStepTimeMs(1 / 30 * 1000);
// Schedule these two systems to run in the fixed timestep at 30fps
builder.schedule.update(GameLoopState, { fixed: ApplyAaabbPhysics });
builder.schedule.update(GameLoopState, { fixed: DetectCollisionTargetHits });
Physics updates are often run as fixed systems. At this time, 2B2D does not directly support interpolating rendering between physics updates.
Signals - Signals are events. They can either be a class that implements the Signal
interface, or just a string if there is no data to convey. For example:
export default class LoadedSignal implements Signal {
static readonly NAME:string = 'LoadedSignal';
readonly name:string = LoadedSignal.NAME;
constructor(public sender:string) { }
}
If sender
wasn't necessary, one could also just use a string as a signal:
export default 'LoadedSignal';
The signal can be handled in a plugin. For example:
export default function GamePlugin(builder: Builder) {
builder.startState(InitState);
builder.signals.handle(LoadedSignal, (update: Update, signals: LoadedSignal[]) => {
update.schedule.enter(MenuState);
});
builder.schedule.enter(MenuState, SpawnLogo);
builder.schedule.exit(MenuState, DespawnLogo);
}
This handler receives the LoadedSignal
and enters the MenuState
state. Note: the system doesn't use the signal data for anything; the signals
argument could have been omitted. Also I generally keep systems in separate files, but they can be inlined as in above.
States - States determine scheduling of systems. Every state goes through three phases:
- One frame of
enter
state. - Frames of
update
state until the state is exited. - One frame of
exit
state.
Enter states are often used to spawn entities. Exit states are often used to despawn entities.
Entities and Components - An entity is a collection of Components. Components are facts about a given entity. An entity can be made up of any number of different components and behavior is often driven by what components an entity has. Components implement the Component
interface. 2B2D comes with a number of components by default, but you will define custom components as well. For example:
export default class Logo implements Component {
static readonly NAME:string = 'Logo';
readonly name:string = Logo.NAME;
constructor(
public timeRemaining: number
) { }
}
export function SpawnLogo(update: Update) {
update.spawn(
new Sprite(logo),
new Position(Vec2.ZERO),
new Logo(1000)
);
}
export function WaitForLogo(update: Update) {
const query = update.ecs.single(Logo);
if (!query)
return;
const [ logo ] = query.components;
logo.timeRemaining -= update.delta;
if (logo.timeRemaining < 0) {
update.schedule.exit(MenuState);
}
}
export function DespawnLogo(update: Update) {
const query = update.ecs.single(Logo);
if (!query)
return;
update.despawn(query.entity);
}
You can query for entities by querying for any entity that matches a list of components. In the above example, we're only matching one component (Logo
), but you can query for multiple, update.ecs.single(Sprite, Position, Logo)
, and only entities with all the components will be returned.
The above examples are querying for a single component (update.ecs.single
), but you can query for all matching entities update.ecs.query
.
You then destrucutre the .components
of each result to get the component values. For a contrived example:
const query = update.ecs.query(Sprite, Position, Logo);
for (const item of query) {
const [ sprite, position, logo ] = item.components;
sprite.tag = 'Idle';
position.position = position.position.add(new Vec2(0, 1));
logo.timeRemaining = logo.timeRemaining - update.delta;
}
Resources - Resources are global object that only ever have one instance, effectively a singleton. Assets are stored in the AssetsResource
. Input is handled through the KeysResource
. Sound is managed through the AudioResource
. You can also create and register custom resources:
export default class GameStateResource implements Resource {
static readonly NAME:string = 'GameStateResource';
readonly name:string = GameStateResource.NAME;
public level: number = Config.StartLevelId;
public health: number = Config.MaxHealth;
}
export function GamePlugin(builder: Builder) {
builder.resource(new GameStateResource());
builder.handle(PlayerHurtSignal, HandlePlayerHurt);
}
export function HandlePlayerHurt(update: Update) {
const gameState = update.resource(GameStateResource);
gameState.health -= 1;
// TODO: Death logic
}
This documentation is far from complete. Your best bet is to read through the Example
folder and see how things are done. You can comment out each plugin in the GamePlugin
plugin (except InitPlugin
) to see how it effects the game.
- 2B2D uses WebGPU for rendering, which is currently only available in Chrome, Edge, and Firefox nightlies (must be enabled).
- The source textures for tilemaps do not currently support padding or spacing between tiles.
- Levels in LDtk should be aligned to the grid. Sometimes the levels get off the grid spacing a bit, and it can confuse the position of colliders and entities.
- The physics engine only has two body types:
StaticBody
which does not move, andKenticBody
which does move but can't pass through anyStaticBody
.KineticBody
entities, however, do not interact with each other and can pass through. - Anchor points are always the center of the objects.
- Spawns, Despawns, and Signal emissions are handled after all the normal systems have executed, meaning that these effectively take effect the frame after they are called.
- 2B2D was developed and is maintained by one guy with no formal background in game development, as a hobby project. Basically I got curious about how GPUs worked and ended up writing a game engine. Use at your own risk.
This engine has not been heavily performance tested, though there was an attempt to be a reasonable balance between performance and ergonomics. Some things to keep in mind:
- Items in the same render order with the same texture can usually be rendered in the same draw call. It's probably better to have a sprite sheet with lots of sprites in a single texture than a bunch of individual files.
- ECS queries are cached. Spawning, despawning, or adding/removing components will clear the cache, so it's best to avoid doing those operations frequently. Maybe keep a pool of entities with
Visible
components to hide/show as necessary if you've got a large swarm of frequently appearing/disappearing objects. - Tilemaps can be drawn in a single call and are generally more performant than spawning individual sprites for each tile.
By default, all sprites are drawn at a depth of 0.5
. You can control this by adding a Depth
component to your sprite entity. Valid depth values must be below one and greater than zero (anything outside this range will not appear). Higher numbers are "further back", smaller numbers are closer to the camera ("on top").
Depth is handled by a depth stencil on the GPU. For fully opaque/transparent objects, this just works. For semi-opaque objects, you will want to use a RenderOrder
component to force the object on top (nearer the camera) to render after any objects it renders on top of. (If a semi-transparent object renders first, you'll see the background through it because of how the depth stencil works). Entities with no RenderOrder
component are rendered first, then entities are rendered in the order set by the engine.rendering.renderOrder
array.
- Echo Chronos: The Broken Timepiece - A simple game made for the Mini Jame Gam #29 game jam in just over a day (in V2 of 2B2D).
- Special thanks to Nhawdge for coming up with the 2B2D name. Quote: "cause there are two b's in your first name. AND TWO DS IN YOUR LAST NAME. I'M GENIUS"
- Graphic assets used in the example game (
platform-bg-tiles.png
,platform-characters.png
, andplatform-tiles.png
) are from thePixel Platformer
asset collection by Kenny. License: Creative Commons Zero, CC0. - This excellent TojiCode blog post on tilemaps was instrumental in creating my tilemap implementation. Also this series on WebGPU best practices is fantastic.