14 KiB
☾
Lance 3D Manual (v1)
Lance 3D is a focused 3D fantasy-console engine for making games with a PS1 / N64 / Saturn kind of visual personality. It’s immediate-mode: you load some assets, set a camera, draw every frame, ship.
Game code is written in CellScript (not JavaScript):
https://cell-lang.org/cellscript/
Networking is v2.
Specifications
-
Target FPS: 60
-
VSync: always on
-
Internal render size: 320×240 (always)
-
Windowing: letterboxed into whatever window size you choose
-
Screen coordinates: origin at bottom-left
- Pixel addressable range: x: 0..319, y: 0..239
- Floats are allowed (subpixel placement), but the result is still “pixel-ish”
-
3D coordinates: right-handed, Z-up
-
Units: 1 unit = 1 meter
-
Angles: radians (
piexists)
Assets:
- Models:
.glb - Textures:
.png/.jpg - Sounds:
.wav/.mp3(converted to 16-bit PCM @ 44.1 kHz) - Music:
.mid/.midivia the embedded chip
Voices (maximum simultaneously):
- PS1: 24
- N64: 24
- Saturn: 32
Voice cap includes everything: play_sound() voices + voices produced by the MIDI/chip.
Hot reload:
- The engine watches and reloads changed models and textures during development.
The big rule: immediate mode
Lance 3D does not keep a “scene.” It draws what you ask it to draw this frame.
If you want:
- a scene tree → you build it
- a global “current material” → you build it
- an entity system → you build it
Lance 3D mostly retains assets, not render intent.
What resets each frame
At the start of each draw() call, Lance 3D resets:
- camera
- lighting
- fog
- 2D clip/scissor
So if you want those, you set them in draw().
Getting started
A Lance 3D “game” is a folder. Asset paths are absolute-from-game-root, and you omit extensions.
"man/model"loads"man/model.glb""tex/brick"loads"tex/brick.png"or"tex/brick.jpg""sfx/hit"loads"sfx/hit.wav"or"sfx/hit.mp3"
Top-level code runs once at startup: load assets, create meshes, initialize state.
The main loop
You don’t write the loop. You register callbacks.
var t = 0
register({
update(dt) { t += dt },
draw() { /* draw every frame */ }
})
update(dt)once per frame,dtin secondsdraw()once per frame
Hello cube
def cube = make_cube(1, 1, 1)
def tex = load_texture("tex/brick")
def mat = {
color_map: tex,
paint: [1, 1, 1, 1],
coverage: "opaque",
face: "single",
lamp: "lit"
}
var a = 0
register({
update(dt) { a += dt },
draw() {
set_style("ps1")
camera_perspective(pi / 3, 0.1, 100)
camera_look_at(3, 3, 2, 0, 0, 0)
set_lighting({
sun_dir: [0.3, -1, 0.2],
sun_color: [1, 1, 1],
ambient: [0.25, 0.25, 0.25]
})
def trs = trs_matrix(0, 0, 0, 0, 0, a, 1, 1, 1)
draw_mesh(cube, trs, mat)
}
})
If you see loud purple, you drew without a material. That’s intentional.
Time, logging, stats
time()→ number Seconds since boot.
log(...args)→ void Print to the log.
stat(name)→ numbername:"draw_calls" | "triangles" | "fps" | "memory_bytes"Throws if unknown.
Style (PS1 / N64 / Saturn)
Style is global and persists until changed.
set_style(style = "ps1")→ voidstyle:"ps1" | "n64" | "saturn"
Triangle limits are soft: warnings, not failures.
Texture & model tiers (low / normal / hero)
Textures and models are treated as belonging to a tier and interpreted through the current style.
| Style | Low | Normal | Hero |
|---|---|---|---|
| PS1 | 64 | 128 | 256 |
| N64 | — | 32 | 64 |
| Saturn | 32 | 64 | 128 |
Defaults:
- Textures:
normal - Models:
normal
You can tag assets:
tag_texture(texture, tier = "normal")→ voidtag_model(model, tier = "normal")→ voidtier:"low" | "normal" | "hero"
Tags are hints to the style system (budget + interpretation). They don’t invent LODs for you.
Environment (camera, lighting, fog)
These reset each draw(). Set them in draw().
Camera
camera_look_at(ex, ey, ez, tx, ty, tz, upx = 0, upy = 0, upz = 1)→ void
camera_perspective(fov = pi/3, near = 0.1, far = 1000)→ void FOV is radians. Throws if planes are invalid.
camera_ortho(left, right, bottom, top, near = -1, far = 1)→ void
Lighting
set_lighting({ sun_dir, sun_color, ambient })→ void All colors are 0..1.sun_diris normalized internally.
Fog
set_fog({ enabled=false, color, near, far })→ void Fog is a first-class style tool.
Transforms
Transforms are opaque TRS blobs created by trs_matrix(...). Functions accept a transform blob or null for identity.
trs_matrix(tx, ty, tz, rx, ry, rz, sx = 1, sy = 1, sz = 1)→ transform
- translation in meters
- rotation is Euler radians, applied in XYZ order
- scale is unitless multiplier
(If you want quaternions, build your own helper and still feed the resulting matrix/blob shape the engine expects.)
Materials
Materials are plain objects with a fixed shape. No shaders.
def brick = {
color_map: load_texture("tex/brick"),
paint: [1, 1, 1, 1],
coverage: "opaque",
face: "single",
lamp: "lit"
}
Fields:
-
color_map: texture | null -
paint: [r,g,b,a](0..1) -
coverage: "opaque" | "cutoff" | "blend""cutoff"uses alpha threshold 0.5
-
face: "single" | "double"- Front faces are clockwise (CW)
-
lamp: "lit" | "unlit"
Transparency:
"blend"draws are depth-sorted back-to-front (simple, stable, not perfect).
Assets and paths
Loads are synchronous. Missing assets return null and log details.
load_texture(path)→ texture | nullload_model(path)→ model | nullload_sound(path)→ sound | nullload_font(path)→ font | null
Asset handles are stable by path:
- calling
load_texture("tex/brick")again returns the same handle - hot reload updates the content behind that handle in development
Drawing order
- 3D world (meshes, models, billboards)
- 2D overlay (sprites, text, 2D primitives)
2D always draws over 3D. To get “sprites in 3D,” use billboards.
Drawing 3D
draw_model(model, transform = null, pose = null)→ void Uses embedded model materials.
draw_mesh(mesh, transform = null, material = null)→ void Ifmaterial=null, uses the missing-material purple.
Billboards (world space)
Billboards face the camera and are anchored at bottom-center.
draw_billboard(texture, x, y, z, size = 1.0, material = null)→ void
size is in meters (height). Aspect ratio comes from the texture.
2D drawing (PICO-8 style)
Everything here is screen space (320×240), anchored at bottom-left, drawn over 3D.
Sprites
draw_sprite(texture, x, y, size = 1.0, material = null)→ void
size is a pixel scale:
size=1.0draws at the texture’s authored pixel size in 320×240 spacesize=2.0doubles it, etc.
Text
Lance 3D ships with a built-in default font. You can also pass a font per draw.
draw_text(text, x, y, scale = 1.0, color = [1,1,1,1], font = null)→ void
\ncreates a new line- unknown glyphs render as
? font=nulluses the built-in font
Simple primitives
pset(x, y, color = [1,1,1,1])→ voidline2(x0, y0, x1, y1, color = [1,1,1,1])→ voidrect(x, y, w, h, color = [1,1,1,1])→ voidrectfill(x, y, w, h, color = [1,1,1,1])→ voidcirc(x, y, r, color = [1,1,1,1])→ voidcircfill(x, y, r, color = [1,1,1,1])→ voidclip(x, y, w, h)→ void (enable)clip()→ void (disable; also resets eachdraw())
Procedural geometry
make_cube(w = 1, h = 1, d = 1)→ meshmake_sphere(r = 1, segments = 12)→ meshmake_cylinder(r = 1, h = 1, segments = 12)→ meshmake_plane(w = 1, h = 1)→ mesh
Animation
anim_info(model)→ any Structured info about available animations.
sample_pose(model, name, t)→ pose | nulltis seconds. Nothing advances unless you advance it.
Audio
Stereo output. Converted to 16-bit PCM @ 44.1kHz.
play_sound(sound, { volume=1, pitch=1, pan=0, loop=false })→ voicestop_sound(voice)→ void
Embedded chip / MIDI:
chip(channel, op, value)→ void Examples:
chip(0, "play_midi", "music/theme")chip(0, "volume", 120)chip(2, "mute", 1)
Voice stealing (when cap is hit) is deterministic:
- lowest priority loses first (chip voices are lowest by default)
- ties: oldest voice loses
Collision (v1: simple queries)
Collision in v1 is stateless queries. No collider world, no layers, no retained physics state.
Types
Use plain objects:
def ray = { origin: [ox, oy, oz], dir: [dx, dy, dz] } // dir should be normalized
def box = { min: [minx, miny, minz], max: [maxx, maxy, maxz] } // AABB
Ray hits return hit | null:
// when hit:
{ dist: number, point: [x,y,z], normal: [nx,ny,nz] }
// when miss:
null
Shape checks
check_collision_spheres(c1, r1, c2, r2)→ boolcheck_collision_boxes(box1, box2)→ boolcheck_collision_box_sphere(box, center, radius)→ bool
Ray queries
get_ray_collision_sphere(ray, center, radius)→ hit | nullget_ray_collision_box(ray, box)→ hit | nullget_ray_collision_mesh(ray, mesh, transform = null)→ hit | null
Mesh collider (cached acceleration)
For repeated raycasts (picking, bullets, LOS checks), you can precompute a mesh collider once:
make_mesh_collider(mesh)→ mesh_collider
This builds an internal acceleration structure (BVH) in mesh local space.
Then use:
get_ray_collision_mesh_collider(ray, mesh_collider, transform = null)→ hit | null
Same result as get_ray_collision_mesh, but faster for repeated queries.
Will this work?
Yes—this is a solid “fantasy-console collision” model if you keep the expectations correct:
- Great for: triggers, overlap tests, ray picking, hitscan weapons, simple AI line-of-sight.
- Not a physics engine: no solver, no continuous collision handling, no automatic contact resolution.
Mesh colliders specifically:
- Great when the mesh is static (level geometry) or the mesh itself doesn’t change.
- Fine for moving instances: pass a different
transformper query. - If the mesh deforms/changes topology, you must rebuild the collider (call
make_mesh_collideragain).
Input
btn(id, player = 0)→ boolbtnp(id, player = 0)→ bool
Button IDs:
- 0 left, 1 right, 2 down, 3 up
- 4 A, 5 B, 6 X, 7 Y
- 8 L, 9 R
- 10 Start, 11 Select
Debug drawing (world space)
dbg_line(a, b, color = [1,0,0])→ voiddbg_aabb(box, color = [1,0,0])→ voiddbg_ray(ray, color = [1,0,0])→ void
Cheat sheet (v1 API)
Core
register({ update(dt), draw() })// start the game looptime()// seconds since bootlog(...args)// print to logstat(name)// runtime stats: draw_calls/triangles/fps/memory_bytes
Style
set_style(style = "ps1")// set visual styletag_texture(texture, tier = "normal")// tag texture as low/normal/herotag_model(model, tier = "normal")// tag model as low/normal/hero
Environment
camera_look_at(ex, ey, ez, tx, ty, tz, upx=0, upy=0, upz=1)// set viewcamera_perspective(fov=pi/3, near=0.1, far=1000)// set perspective projectioncamera_ortho(left, right, bottom, top, near=-1, far=1)// set ortho projectionset_lighting({ sun_dir, sun_color, ambient })// set sun + ambientset_fog({ enabled=false, color, near, far })// set fog
Transforms
trs_matrix(tx, ty, tz, rx, ry, rz, sx=1, sy=1, sz=1)// build transform blob (TRS)
Assets
load_texture(path)// load png/jpg, returns texture or nullload_model(path)// load glb, returns model or nullload_sound(path)// load wav/mp3, returns sound or nullload_font(path)// load a font, returns font or null
Procedural geometry
make_cube(w=1, h=1, d=1)// cube meshmake_sphere(r=1, segments=12)// sphere meshmake_cylinder(r=1, h=1, segments=12)// cylinder meshmake_plane(w=1, h=1)// plane mesh
Drawing 3D
draw_model(model, transform=null, pose=null)// draw model with embedded materialsdraw_mesh(mesh, transform=null, material=null)// draw mesh with optional material overridedraw_billboard(texture, x, y, z, size=1.0, material=null)// camera-facing sprite in world
Drawing 2D
draw_sprite(texture, x, y, size=1.0, material=null)// screen-space sprite over 3Ddraw_text(text, x, y, scale=1.0, color=[1,1,1,1], font=null)// screen-space textpset(x, y, color=[1,1,1,1])// set pixelline2(x0, y0, x1, y1, color=[1,1,1,1])// draw linerect(x, y, w, h, color=[1,1,1,1])// rectangle outlinerectfill(x, y, w, h, color=[1,1,1,1])// filled rectanglecirc(x, y, r, color=[1,1,1,1])// circle outlinecircfill(x, y, r, color=[1,1,1,1])// filled circleclip(x, y, w, h)// enable 2D clippingclip()// disable 2D clipping
Animation
anim_info(model)// inspect available animationssample_pose(model, name, t)// sample pose at time t, returns pose or null
Audio
play_sound(sound, { volume=1, pitch=1, pan=0, loop=false })// play a sound voicestop_sound(voice)// stop a playing voicechip(channel, op, value)// control embedded chip / MIDI
Collision
check_collision_spheres(c1, r1, c2, r2)// sphere-sphere overlapcheck_collision_boxes(box1, box2)// AABB-AABB overlapcheck_collision_box_sphere(box, center, radius)// AABB-sphere overlapget_ray_collision_sphere(ray, center, radius)// ray vs sphere, returns hit or nullget_ray_collision_box(ray, box)// ray vs AABB, returns hit or nullget_ray_collision_mesh(ray, mesh, transform=null)// ray vs mesh, returns hit or nullmake_mesh_collider(mesh)// build cached collider (BVH) from meshget_ray_collision_mesh_collider(ray, mesh_collider, transform=null)// fast ray vs mesh collider
Input
btn(id, player=0)// button heldbtnp(id, player=0)// button pressed this frame
Debug
dbg_line(a, b, color=[1,0,0])// debug linedbg_aabb(box, color=[1,0,0])// debug AABBdbg_ray(ray, color=[1,0,0])// debug ray