A simplified game platform for rapidly prototyping and experimenting with 2D game ideas on the web.
The goal of this project is to provide a streamlined game development environment with a fixed resolution, fixed timestep, direct framebuffer access, and a strict separation between runtime (host) and game (cartridge).
This is not a game engine and not a framework. It is intentionally minimal, opinionated, and low‑level.
-
Console / Cartridge split
- The WASM file is the cartridge (code only)
- The host (JavaScript) is the console (RAM, input, timing, rendering)
-
Deterministic execution
- Fixed 60 Hz update loop
- No clocks or async APIs exposed to the cartridge
- Replay‑ and save‑state‑friendly by design
-
Direct framebuffer access
- 320 × 240 resolution
- 32‑bit RGBA framebuffer (24‑bit color, alpha unused)
- Cartridge writes pixels directly into shared memory
-
Web‑native
- Runs in any modern browser
- Zero‑copy rendering via
<canvas> - Simple HTML‑based dev tooling
-
Fast iteration
- Simple build pipeline
- Designed for hot reload and debugging
- A simplified game platform for rapid prototyping
- A learning and experimentation platform
- A software‑rendered, pixel‑based system
- Close to retro hardware programming models
- A full game engine
- A scene graph or ECS framework
- A high‑performance GPU renderer
- A general WebAssembly application template
┌──────────────────────────────┐
│ Host (JavaScript) │
│ - Canvas rendering │
│ - Input collection │
│ - Fixed timestep │
│ - RAM allocation │
│ - Cartridge loading │
└──────────────┬───────────────┘
│ shared memory
┌──────────────┴───────────────┐
│ Cartridge (AssemblyScript) │
│ - Game logic │
│ - Software rendering │
│ - Deterministic state │
└──────────────────────────────┘
The cartridge has no access to:
- DOM APIs
- Time
- Events
- Storage
- Rendering APIs
It only sees memory and exported functions.
tinyforge/
├─ index.html # Canvas and page shell
├─ memory-viewer.html # Memory inspector UI
├─ styles.css # Host styling
├─ stylesVC.css # Virtual console controls styling (mobile and WPA)
├─ icons/
│ └─ favicon.svg
│
├─ src/
│ ├─ web/ # Host runtime (TypeScript)
│ ├─ assembly/
│ │ ├─ sdk/ # Game SDK (AssemblyScript)
│ │ ├─ games/ # Game cartridges (AssemblyScript)
│ │ └─ tsconfig.json # AssemblyScript config
│ └─ memory-map.ts # Shared memory constants
│
├─ dist/
│ ├─ web/ # Compiled host JS
│ └─ memory-map.js
├─ assets/
│ └─ cartridges/ # Compiled WASM files
│
└─ package.json # Build scripts and dependencies
Owns everything that would be considered hardware on a real console:
- RAM allocation
- Frame timing
- Input devices
- Rendering
- Cartridge loading
The host populates the game dropdown by fetching the assets/cartridges/ directory
listing and extracting .wasm filenames. This is meant for local dev servers
that expose directory indexes. If your hosting setup hides directory listings,
add a fallback list or provide a manifest endpoint.
Contains only game code:
- Update logic
- Drawing logic
- Direct writes to the framebuffer
The cartridge is treated as ROM‑like code.
Main game file: {gameName}.ts
Contains the exported lifecycle functions: init(), update(), draw().
Supporting files: {gameName}/ subfolder
For complex games that need multiple modules, place supporting scripts in a subfolder named exactly like the main game file (without the .ts extension).
Example structure:
src/assembly/games/
├─ crocodiles.ts # Main game file
├─ crocodiles/ # Supporting files
│ ├─ player.ts
│ ├─ types.ts
│ ├─ utils.ts
│ └─ ...
├─ starlinePursuit.ts # Main game file
└─ starlinePursuit/ # Supporting files
├─ types.ts
├─ generateStarmap.ts
├─ drawStarmap.ts
└─ ...
This keeps game code organized and isolated from other games.
- Resolution: 320 × 240
- Format: RGBA8888 (little-endian)
- Size: 307,200 bytes
Offset 0x000000 ──────────────────────
Framebuffer (320 × 240 × 4)
Pixels are written directly by the cartridge using integer math.
The host creates a zero-copy ImageData view into WASM memory and blits it to a <canvas>.
The console exposes a fixed, shared linear memory to the cartridge.
All offsets are absolute and part of the hardware contract.
Address Size Description
----------------------------------------------
0x000000 307,200 B Framebuffer (RGBA8888, 320×240×4)
0x04B000 2 B Keyboard Input (current + previous buttons)
0x04B008 6 B Mouse Input (x, y, current + previous buttons)
0x04B010 16 B Sprite Table Header
0x04B020 N entries Sprite Name Lookup (N × 32 bytes)
... N entries Sprite Info Table (N × 16 bytes)
... ~128 KB Sprite Pixel Data (RGBA)
0x06B810 4 B SDK RNG Seed (i32)
0x06B814 ~82 KB Game RAM (available for game state)
Detailed Layout:
Framebuffer (0x000000 - 0x04AFFF):
- 320 × 240 × 4 bytes = 307,200 bytes
- Format: RGBA8888 (little-endian)
- Write-only for cartridge
Keyboard Input (0x04B000 - 0x04B007):
+0: u8 current button state (bitmask)+1: u8 previous button state (for edge detection)
Mouse Input (0x04B008 - 0x04B00F):
+0: i16 mouse X coordinate (-1 if outside canvas)+2: i16 mouse Y coordinate (-1 if outside canvas)+4: u8 current button state (bit 0=left, 1=right, 2=middle)+5: u8 previous button state (for edge detection)
Sprite Tables (0x04B010+):
- Table header (16 bytes):
+0: u16 entry count (N)+4: u32 name lookup offset (from table base)+8: u32 info table offset (from table base)+12: u32 pixel data offset (from table base)
- Name lookup table (N entries, UTF-16, max 16 chars = 32 bytes each)
- Sprite info table (N entries, 16 bytes each):
+0: u32 dataOffset (absolute address)+4: u32 dataSize (bytes per frame)+8: u16 width+10: u16 height+12: u8 cols+13: u8 rows
Sprite Pixel Data (follows info table):
- RGBA8888 (4 bytes per pixel)
- Managed by host, loaded from
assets/sprites/
SDK RNG Seed (0x06B810 - 0x06B813):
- 4-byte u32 seed used by SDK
random()andrandomRange() - Initialized by the host and editable in devtools
Game RAM (0x06B814+):
- Available for game state, variables, and data structures
- Use
RAM_STARTconstant from SDK - Store persistent game state here (not in module variables)
- Use
@unmanagedstructs withchangetype<T>(RAM_START)for type-safe access
Notes:
- Memory is allocated by the host (JS) at 1 MB total (16 pages)
- Memory size is fixed at startup
- Memory does not grow at runtime
- The framebuffer region should be treated as write-only by the cartridge
- Input/mouse regions are read-only for the cartridge (written by host)
- Sprite data is managed by the host (cartridge uses sprite IDs)
- All addresses are defined in
src/memory-map.tsand shared between host and SDK
Input is snapshot‑based, not event‑based.
- The host collects keyboard state
- State is packed into a bitmask
- Both current and previous frame masks are passed to the cartridge
- This allows detecting button press vs. hold
The Button enum is provided by the console SDK:
import { Button } from "./console";
// Button values:
// UP = 1 << 0, DOWN = 1 << 1, LEFT = 1 << 2, RIGHT = 1 << 3
// A = 1 << 4, B = 1 << 5, START = 1 << 6Detecting button press (not hold):
export function update(input: i32, prevInput: i32): void {
const pressed = input & ~prevInput;
if (pressed & Button.A) {
// A was just pressed this frame
}
}The cartridge never sees individual key events.
The console SDK provides logging functions that output to the HTML console panel.
import { log, warn, error } from "./console";
log("Player initialized"); // Blue entry [LOG]
warn("Health low"); // Yellow entry [WARN]
error("Invalid state"); // Red entry [ERROR]To log dynamic values without string concatenation, use the interpolation variants:
import { logi, logf, warni, warnf, errori, errorf } from "./console";
// Integer parameters (i64)
logi("Score: {}, Lives: {}", score, lives);
warni("Low health: {}", health);
errori("Invalid state: {}", state);
// Floating-point parameters (f64)
logf("Position: ({}, {})", playerX, playerY);
warnf("Low speed: {}", velocity);
errorf("Invalid position: {}", x);Available functions:
logi(),warni(),errori()- Accept up to 4 integer parameters (i64)logf(),warnf(),errorf()- Accept up to 4 floating-point parameters (f64)- Use
{}as placeholders in the message string - Parameters are interpolated on the JavaScript side (zero allocation in WASM)
Important notes:
- All functions accept string literals only for the message
- No string concatenation or manipulation in WASM
- Parameters are automatically converted to strings by the host
- All messages are timestamped in the console panel
Examples:
export function init(): void {
log("Game started");
logi("Level: {}", currentLevel);
}
export function update(input: i32, prevInput: i32): void {
if (playerHealth < 20) {
warni("Low health: {}", playerHealth);
}
if (score > highScore) {
logi("New high score: {} (old: {})", score, highScore);
}
if (invalidCondition) {
errori("Invalid state: {}", gameState);
}
}Each log type appears with a distinct color in the console panel below the game canvas.
The console provides a Web Audio API-based audio system with support for sound effects and background music.
Audio files are organized in two directories:
assets/
├─ sfx/ # Sound effects
│ ├─ 0-tap.wav
│ ├─ 1-explosion.wav
│ └─ ...
└─ music/ # Background music
├─ 0-gameplay.wav
└─ ...
Files are ID-based: the filename ID is a string parsed from the prefix before ~/-.
Only the ID is required, and it must be ≤ 16 characters.
import { playSfx, playMusic, stopMusic } from './console';
// Play a sound effect
playSfx(sfxId: string, volume: f32); // volume: 0.0 - 1.0
// Play background music (loops automatically)
playMusic(musicId: string, volume: f32);
// Stop background music
stopMusic();This means:
- Audio files load during startup
- Audio will not play until the user interacts with the page
- Calling
playMusic()ininit()will fail silently
Best Practice:
// ❌ WRONG - Called in init(), before user interaction
export function init(): void {
playMusic("gameplay", 0.7); // Will fail silently
}
// ✅ CORRECT - Called after user clicks start button
export function update(input: i32, prevInput: i32): void {
if (startButtonPressed) {
playMusic("gameplay", 0.7); // Works!
}
}Standard pattern:
- Show a "Press Start" screen in
init() - Wait for user to click or press a button
- Start music in
update()after detecting the button press - Play sound effects normally during gameplay
import { playSfx, playMusic, stopMusic } from "./console";
// Audio IDs
const SFX_JUMP = "jump";
const SFX_COIN = "coin";
const SFX_HIT = "hit";
const MUSIC_GAMEPLAY = "gameplay";
const MUSIC_GAME_OVER = "game_over";
let gameStarted = false;
export function update(input: i32, prevInput: i32): void {
// Start music after user presses start
if (!gameStarted && input & Button.START) {
gameStarted = true;
playMusic(MUSIC_GAMEPLAY, 0.6);
}
// Play sound effects during gameplay
const pressed = input & ~prevInput;
if (pressed & Button.A) {
playSfx(SFX_JUMP, 0.5);
}
// Stop music on game over
if (playerDied) {
stopMusic();
playSfx(SFX_HIT, 0.8);
}
}- Music loops automatically until
stopMusic()is called - Multiple sound effects can play simultaneously
- Only one music track plays at a time
- Volume range:
0.0(silent) to1.0(full volume) - Audio files are loaded at startup but decoded on-demand after first interaction
The console provides a sprite system for drawing images with transparency and alpha blending support.
Sprite images are stored in the assets/sprites/ directory:
assets/
└─ sprites/ # Sprite images
├─ flag.png
├─ player-test#1.png
├─ someTiles~4x3.png # Sprite sheet: 4x3 grid
└─ ...
Single sprites:
- Format:
{id}.png,{id}-{info}.png, or{id}~{props}.png - The string
id(≤ 16 chars) becomes the sprite name
Sprite sheets:
- Format:
{id}~{COLS}x{ROWS}-name.png(e.g.,tiles~4x3.png) - Automatically split into frames
- Grid dimensions: COLS (across) × ROWS (down)
- Order: left-to-right, top-to-bottom
- Everything after dimensions is optional/ignored
Example sprite sheet:
File: tiles~4x3.png (128x96 pixels)
Grid: 4 columns × 3 rows = 12 sprites
Each sprite: 32×32 pixels
Supported formats:
- PNG (with transparency)
- JPG (no transparency)
import { drawSprite, s } from './console';
// Resolve sprite by name (and optional sheet coords)
s(name: string, x: i32 = 0, y: i32 = 0): i32;
// Draw sprite at position (packed ID from s())
drawSprite(id: i32, x: i32, y: i32, flipX?: bool, flipY?: bool);The sprite system supports full alpha blending:
- Fully transparent (alpha = 0): Pixel is skipped
- Fully opaque (alpha = 255): Pixel is drawn directly (no blending)
- Semi-transparent (0 < alpha < 255): Pixel is alpha-blended with framebuffer
The blending formula is: result = src * alpha + dst * (1 - alpha)
All pixels written to the framebuffer have alpha = 255 (fully opaque).
import { drawSprite, s } from "./console";
// Draw a single sprite
drawSprite(s("flag"), 100, 100);
// Draw a sprite sheet frame (column, row)
drawSprite(s("tiles", 1, 0), 50, 50, true, false);
// Sprites with semi-transparent pixels will blend smoothly
drawSprite(s("shadow"), 200, 150); // Shadow sprite with alpha = 128- Sprites are loaded at startup from
assets/sprites/ - Maximum sprite entries: 256
- Sprite data is stored after the sprite tables in shared memory
- Transparency is handled automatically
- Alpha blending works with any alpha value (0-255)
- Fixed timestep: 60 Hz
- Update and render are decoupled
- The host owns all timing
Flow:
while accumulator >= dt:
update(input)
draw()
blit framebuffer
This guarantees deterministic simulation regardless of frame rate.
Memory is allocated by the host, not the cartridge.
- The host creates a fixed
WebAssembly.Memory - The cartridge imports it
- Memory does not grow at runtime
This mirrors real hardware:
- Console owns RAM
- Cartridge assumes a known memory map
This also enables:
- Hot reload
- Save states
- Memory inspection
A cartridge is a pure AssemblyScript module that:
- Imports the console SDK (
console.ts) - Exports a small, fixed API (
init,update,draw,WIDTH,HEIGHT) - Writes directly to the framebuffer
- Stores game state in RAM (not module variables)
The SDK provides:
- Memory declarations and memory map constants
- Display constants (WIDTH, HEIGHT)
- Input constants (Button enum)
- Console logging functions (log, warn, error)
import { clearFramebuffer, pset, Button, RAM_START } from "./console";
// Game state persisted in RAM using @unmanaged struct
@unmanaged
class Vars {
x: i32; // 0 - player x
y: i32; // 4 - player y
}
const vars = changetype<Vars>(RAM_START);
// === lifecycle ===
export function init(): void {
clearFramebuffer(0xff000000);
// Initialize player position in RAM
vars.x = 160;
vars.y = 120;
log("Starting!");
}
export function update(input: i32, prevInput: i32): void {
// Movement logic - use buttonDown() for continuous movement
if (input & Button.LEFT) vars.x--;
if (input & Button.RIGHT) vars.x++;
if (input & Button.UP) vars.y--;
if (input & Button.DOWN) vars.y++;
}
export function draw(): void {
// Clear buffer
clearFramebuffer(0x000000);
// And draw point at player's position
pset(vars.x, vars.y, c(0xffffff));
}Why @unmanaged structs?
The @unmanaged decorator tells AssemblyScript to not use automatic memory management for this class. This allows us to:
- Cast a memory address directly to a struct type using
changetype<T>(address) - Access game state as struct fields instead of manual offset calculations
- Get better type safety and IDE autocomplete
- Avoid manual
getI32/setI32calls for every variable access
Important notes:
- Struct fields are laid out sequentially in memory (x at offset 0, y at offset 4)
- The struct size must fit within your allocated RAM region
- All cartridges share the same RAM starting at
RAM_START
The cartridge:
- Has no access to time
- Has no access to events
- Has no access to rendering APIs
All interaction happens through memory and exported functions.
- Node.js
- AssemblyScript
From the project root:
npm install
Build everything (games + host):
npm run build
Build all games (WASM only):
npm run build:games
Build a single game (WASM only):
npm run build:games gameName
gameName must be the exact .ts filename without the extension.
Build all games (debug):
npm run build:debug
Build host only (web runtime):
npm run build:host
Web runtime:
npm run watch:host
WASM games:
npm run watch:games
Build scripts:
npm run build- Build everything (host + all games), then uploadnpm run build:host- Build only the web runtime (TypeScript → JavaScript), then uploadnpm run build:games- Build all games (AssemblyScript → WASM), then uploadnpm run build:games <gameName>- Build a single game by name (e.g.,npm run build:games snake)npm run build:debug- Build all games with debug symbols and source maps
Watch scripts:
npm run watch:host- Auto-rebuild host on file changesnpm run watch:games- Auto-rebuild games on file changes
Development scripts:
npm run serve- Start local HTTP server on port 8080npm run dev- Build everything, then start server
Utility scripts:
npm run upload- Manually trigger FTP upload to remote servernpm run check:alloc <gameName>- Check a game for memory allocation symbols
The check:alloc script helps verify that your game doesn't contain any dynamic memory allocation:
npm run check:alloc <gameName>What it does:
- Builds a debug version of the specified game
- Uses
wasm-objdumpto inspect the WASM binary - Searches for allocation-related symbols:
__new,__alloc,__realloc,__free,memory.grow,malloc - Reports any found symbols (indicating allocation code)
Prerequisites:
- Requires
wasm-objdumpto be installed and in your PATH - Part of the WebAssembly Binary Toolkit (WABT): https://github.com/WebAssembly/wabt
Example usage:
npm run check:alloc snake
# Output: "No allocation symbols found." (good!)
npm run check:alloc myGame
# Output: "Found allocation symbol: __new" (bad - fix allocation code!)When to use:
- After writing new game code with arrays or strings
- When debugging runtime errors related to missing
__newfunction - To validate that zero-allocation patterns are working correctly
- Before committing new games
Troubleshooting:
- If
wasm-objdumpis not found, install WABT for your platform - The script builds to
tmp/debug-builds/to avoid overwriting production builds - Debug builds include all symbols, making allocation detection reliable
The console includes a game selector in the devtools panel. Use the dropdown to select a game and click "Load Game" to switch between cartridges at runtime without refreshing the page.
You must serve the project over HTTP (not file://).
From the project root:
python -m http.server 8080
Then open:
http://localhost:8080/index.html
A VS Code Live Server or any static server also works.
Builds trigger an FTP sync via the postbuild, postbuild:games, and postbuild:host hooks.
Create a .env file in the project root:
FTP_HOST=yourdomain.com
FTP_USER=username
FTP_PASS=password
The file is ignored by Git.
dist/→/dist*.html,*.css,manifest.json,sw.js→/
npm run upload
The cartridge uses --runtime stub which provides zero heap allocation.
This is enforced at runtime (not compile time):
lowMemoryLimit: 0- No heap memory availablememoryBase: 0- No runtime bookkeeping memoryexportRuntime: false- No runtime functions exported- If allocation code exists, the WASM will fail to instantiate (missing
__newfunction) - AssemblyScript does not prevent writing allocation code, it just won't run
What you CANNOT use:
new Array(),new String(),new Object()- String concatenation or manipulation
- Closures that capture variables
- Any standard library function that allocates
What you CAN use:
- Primitive types:
i32,f32,u32,i64, etc. load<T>()andstore<T>()for memory access- Local variables (stored on the stack)
- Inline functions
- Fixed-size loops
- Zero-allocation utilities (see below)
Why this constraint:
- Guarantees deterministic execution (no GC pauses)
- Simplifies reasoning about memory layout
- Matches retro hardware programming model
- Enables save states and hot reload
The SDK provides utilities that work within the no-allocation constraint:
Instead of string concatenation:
// ❌ WRONG - This allocates memory!
warn("Crocodile " + index.toString() + " not found");
// ✅ CORRECT - Zero allocation
warni("Crocodile {} not found", index);Use logi/logf, warni/warnf, errori/errorf for interpolation (see Console Logging section).
UncheckedArrayView<T> - Zero-allocation array backed by pre-allocated memory:
import { UncheckedArrayView } from "./console";
// Calculate memory size needed
const GRID_SIZE = 100;
const gridBytes = UncheckedArrayView.sizeInMemory<u8>(GRID_SIZE); // 100 bytes
// Reserve memory in your RAM layout
@unmanaged
class Vars {
playerX: i32; // 0
playerY: i32; // 4
// Grid data starts at offset 8
}
// Create array view over pre-allocated memory
const grid = UncheckedArrayView.fromAddress<u8>(RAM_START + 8);
// Use like a normal array
grid.set(10, 42);
const val = grid.get(10);
grid.fill(0, GRID_SIZE);
// Or use bracket notation
grid[10] = 42;
const val = grid[10];ArrayView<T, U> - Array with dynamic length tracking:
import { ArrayView } from "./console";
// Calculate memory size (includes metadata)
const CAPACITY = 50;
const size = ArrayView.sizeInMemory<u16>(CAPACITY); // 4 + 100 = 104 bytes
// Create array with length tracking
const items = ArrayView.fromAddress<u16>(RAM_START + 200);
items.capacity = CAPACITY;
items.clear();
// Dynamic operations
items.push(42); // Add element
const val = items.get(0); // Get element (42)
const len = items.length; // Current length (1)
const found = items.includes(42); // Search (true)
items.clear(); // Reset to empty
// Bracket notation supported
items[0] = 99;
const x = items[0];Key differences:
UncheckedArrayView<T>- No metadata, just raw array data. You manage length manually. No bounds checking.ArrayView<T, U>- Includeslengthandcapacitymetadata (2 * sizeof<U> bytes). Includes bounds checking.- Both use
@unmanagedpattern - no heap allocation, just memory reinterpretation. - Memory must be pre-allocated in your game's RAM layout.
- Use
U = u8for small arrays (max 255 elements),U = u16for larger (max 65535).
Vec2i - 2D integer vector for coordinate pairs:
import { Vec2i, RAM_START } from "./console";
// WARNING: Using 'new Vec2i()' allocates memory!
// For zero allocation, pre-allocate in RAM and use fromAddress()
// Define RAM layout (Vec2i needs 8 bytes: x and y as i32)
enum Var {
PLAYER_POS = 0, // 8 bytes
ENEMY_POS = 8, // 8 bytes
}
// ❌ WRONG - This allocates memory!
const pos = new Vec2i(10, 20);
// ✅ CORRECT - Zero allocation
const playerPos = Vec2i.fromAddress(RAM_START + Var.PLAYER_POS);
playerPos.x = 10;
playerPos.y = 20;
const enemyPos = Vec2i.fromAddress(RAM_START + Var.ENEMY_POS);
enemyPos.set(50, 100); // Set both coordinates at onceImportant: Even though Vec2i is marked @unmanaged, the new keyword still triggers allocation. Always use fromAddress() for zero-allocation access to pre-allocated memory.
The SDK provides drawString() and drawNumber() for rendering text:
import { drawString, drawNumber } from "./console";
// Draw text (UPPERCASE ONLY)
drawString(10, 10, "SCORE:", 0xffffffff);
drawNumber(60, 10, score, 0xffffffff);Important limitations:
drawString()only supports UPPERCASE letters (A-Z), numbers (0-9), and punctuation- Supported punctuation:
:!?.,-/\'+_*"` - Lowercase letters will not render correctly
- Always use uppercase strings:
"GAME OVER"not"Game Over" - Characters are 6×10 pixels, spaced 8 pixels apart horizontally
- Use
drawNumber()for rendering integer values without string allocation
- No floating‑point math in hot paths
- Prefer integer arithmetic
- Think like a software renderer
- Store game state in RAM (using load/store), not module variables
- This enables hot reload and save states
- Module variables don't persist across WASM reloads
This project intentionally favors clarity and control over abstraction.
TinyForge uses zero-allocation array views for all game state. This ensures deterministic execution and compatibility with the stub runtime (no heap). The SDK provides several array types:
-
UncheckedArrayView
- Fixed-size, no bounds checking, no length tracking.
- Use for raw buffers or when you manage length manually.
- Example:
const grid = UncheckedArrayView.fromAddress<u8>(RAM_START + offset);
-
ArrayView
- Fixed-size, with length and capacity tracking.
- Bounds-checked, supports
push(),clear(), etc. - Example:
const items = ArrayView.fromAddress<u16>(RAM_START + offset);
-
UncheckedArrayObjView
- For arrays of
@unmanagedstructs, no bounds or length tracking. - Use when you want direct struct access and manage length yourself.
- Example:
const enemies = UncheckedArrayObjView.fromAddress<Enemy>(RAM_START + offset, count);
- For arrays of
-
ArrayObjView
- For arrays of
@unmanagedstructs, with length/capacity tracking. - Bounds-checked, supports
push(),clear(), etc. - Example:
const crocos = ArrayObjView.fromAddress<Croco>(RAM_START + offset, capacity);
- For arrays of
To avoid manual offset math and ensure all game state is stored in RAM, use StaticMemoryAllocator:
import { StaticMemoryAllocator } from "../sdk/memoryAllocator";
// Allocate from RAM_START
const mem = StaticMemoryAllocator.fromAddress(RAM_START);
// Allocate a struct
export const gameState = mem.allocStruct<GameState>(
offsetof<GameState>("score") + sizeof<i32>(),
);
// Allocate arrays of primitives
export const queueBFS = mem.allocArray<u16>(256); // ArrayView<u16>
export const directionX = mem.allocUncheckedArray<i32>(4); // UncheckedArrayView<i32>
// Allocate arrays of objects
export const crocos = mem.allocUncheckedObjArray<Croco>(
offsetof<Croco>("targetY") + sizeof<u8>(),
NB_CROCOS,
); // UncheckedArrayObjView<Croco>- UncheckedArrayView/UncheckedArrayObjView:
⚠️ No bounds checking! Accessing out of bounds will corrupt memory. - allocArray/allocUncheckedArray:
⚠️ Only for primitive number types (i32, u8, f32, etc.)—not for@unmanagedobjects.
export const crocos = mem.allocUncheckedObjArray<Croco>(
offsetof<Croco>("targetY") + sizeof<u8>(),
NB_CROCOS,
);
export const queueBFS = mem.allocArray<u16>(256);
export const directionX = mem.allocUncheckedArray<i32>(4);export const stars = mem.allocUncheckedObjArray<Star>(
offsetof<Star>("y") + sizeof<i32>(),
STAR_COUNT,
);
export const playerPath = mem.allocArray<u16>(MAX_PATH_LENGTH);- Always allocate all game state at startup using StaticMemoryAllocator.
- Never use dynamic allocation (
new Array(), etc.). - Access arrays directly (e.g.,
crocos.get(i)), do not use helper functions for indirection. - Store all persistent state in RAM, not in module-level variables.
This pattern ensures your game is compatible with TinyForge’s zero-allocation runtime and supports hot reload, save states, and deterministic replay.
- Build-time allocation detection
- Pre-build linting to catch dynamic allocation such as
new Array(),new String(), etc. - TypeScript type deprecation for forbidden constructs
- Pre-build linting to catch dynamic allocation such as
- Sprite blitter helpers
- Tilemap helpers
- Hot reload preserving RAM
- Save states and replays
None of these are required to make games.
This project treats WebAssembly as a virtual console CPU, not as a web optimization target.
Constraints are a feature.
By removing time, events, and rendering APIs from the cartridge, games become:
- Easier to reason about
- Easier to debug
- Easier to replay
- Easier to port
If you enjoy programming close to the metal, this project is for you.
MIT (or choose your own)
Happy hacking 🚀