k4
Are you an idiot? Are you a fucking dummy dumb fucky dumb fuck fuck who literally can't make a Hello World without asking which game engine you should use, then you are too young to view this page.
k4 is a physics-based, minimalist multiplayer 3D game framework.
It's core is relatively small, however it does provide:
- Highly wide-compatibility graphics: minimum OpenGL 1.5
- An audio subsystem (k3Mix)
- A menu subsystem (k3Menu)
- A physics subsystem (Open Dynamics Engine)
- A tiny entity subsystem
- Builtin first-person and third-person controls
- Multiplayer with an authoritative server model, optional peer-to-peer connectivity or IPv6
- Client-side prediction & server reconciliation
- A scripting interface, currently implemented with Lua 5.3
Besides the above, everything is expected to be realized at the scripting level.
Windows and Linux are natively supported. OpenGL ES support is planned, but low-priority. There are no plans for Mac support.
The largest limitation as of now is the lack of rendering layers, which prevents certain effects such as portals or split-screen rendering.
k4 accepts command-line parameters at launch in the form key=value
, which include
script
: the script to automatically launch, stringres%
: game resolution, percentage of the window sizecore
: override the decision to use the Core OpenGL profile, 0 or 1
Scripting
Upon launch, k4 will automatically load a script within the assets
directory. By default this is lvl1
, in assets/lvl1.lua
.
The smallest possible script is the following:
return {
load = function()
end
}
But this ignores the fact that scripts must set up controls, the player entity, menus, and more. As is, the scene is truly nothingness.
Scripts may load resources such as models, sounds, textures, and so on. Doing so is recommended only within the load
function, otherwise loaded resources will stay loaded even after the engine switches scripts.
When the load
function is called, entities and other items from the old scene are still left over. For most cases, it is recommended to immediately do game.cleanup()
to reset the subsystems. The load
function may assign functions to callbacks such as game.render
, game.render2d
or game.update
, or any of game.triggers[i]
.
The module everything revolves around is game
, which interfaces with k4.
game.ref(type: string, name: string): any
Get a resource. If doesn't exist, load. type
may be any of mdl
, physics
, stream
, tex
, mat
or font
. The resource will be loaded from the first source with a matching type and name. Resources are reference-counted, and will be unloaded when there are no uses.
local healthbar = game.ref("tex", "healthbar.dif.png")
local bgm_source = game.ref("stream", "bgm.ogg")
game.batch(r: k3renderable, p: vec3, anchor: mat4)
Send a 3D renderable to the render queue at the position p
. anchor
is an optional model matrix, which is multiplied by the position vector. For example, the matrix game.camera
allows you to render relative to the camera view. This function should be called only within the game.render
callback.
game.batch(gun, {0.2, -0.2, -0.5}, game.camera)
game.firstperson(fp: boolean)
Switch between first-person and third-person views.
game.firstperson(false)
game.ray(max_length: number): k4ray
Immediately trace a ray in the physical scene (not graphical) from the player perspective. Returns an instance of the ray.
-- Shoot laser
local ray = game.ray(60)
local hit_pos = ray.pos
print("Hit ray at ", hit_pos[1], hit_pos[2], hit_pos[3])
print("Hit entity ", ray.hit)
game.set_texture_reduction(order: integer)
Halve the resolution of all textures in the resource system order
times.
-- Half resolution
game.set_texture_reduction(1)
game.cleanup()
Reset all entities, triggers and other scriptable features in k4. Recommended to call this function immediately upon a script load, before setting up the scene.
game.cleanup()
game.load(script: string)
Programatically load to a new script. The scene is not cleaned by this action.
game.load("lvl2")
game.setphys(x: k4trimesh)
Sets an immutable, static physical mesh for the entire scene. x
may be nil
. There may be at most one static mesh in the scene.
local phys = game.ref("physics", "scene")
game.setphys(phys)
game.skybox(texture: k3tex)
Change the skybox texture of the scene. texture
must be a cubemap texture.
local sb = game.ref("tex", "sky1.cub.png")
game.skybox(sb)
game.set_reduction(resolution: number)
Change the graphics resolution to resolution
percent of the window resolution.
-- Half resolution
game.set_reduction(0.5)
Controls
k4 stores a mapping of "controls" to inputs within the game.controls
table. Six controls are reserved by k4 for standard 3D controls: forward
, back
, left
, right
, grab
and jump
. The script must assign these so that the game becomes playable. The simplest method, though worst for accessibility, is to hardcode the options like so:
game.controls["forward"] = "W"
game.controls["back"] = "S"
game.controls["left"] = "A"
game.controls["right"] = "D"
game.controls["jump"] = " "
game.controls["grab"] = 0
Integers 0 through 2 refer to mouse buttons.
Custom controls may be bound. If a control is either pressed or released, the game.ctrl
handler will be called.
function game.ctrl(control_name, action)
if control_name == "shit" then
if action == 0 then
-- Pressed (release your shit)
elseif action == 2 then
-- Released (hold in your shit)
end
end
end
The only input that may not be bound to is the Escape key. If said key is pressed, k4 will instead call the game.escape
callback function.
Entities
k4 intentionally provides very little to customize your entities with.
game.addentity
accepts an entity descriptor and adds an entity to the scene, of which there may be at most 65535. There are four entity components exposed to the script side: physics
, render
, movement
, boned
.
The physics
component grants an entity a shape in the physical scene. The descriptor of the physics
component is mostly self-explanatory. The shape is determined by the existence of either box
, sphere
or capsule
keys in the physics
component descriptor. Exactly one must be defined. Note the trigger
key, which is explained later.
The render
component exposes the entity to the renderer. The only valid key within the render
component is mdl
, which may be a string with the name of a resource. k4 will load it similarly to the call game.ref("mdl", ...)
.
Without the movement
component, the entity cannot move. The only valid keys within the movement
component is dir
, a vec3 which defines the direction in which the entity should move, and jump
, which specifies whether the entity wishes to jump. If a player has been assigned to this entity, its movement
component will be continually modified to correspond to the player's inputs.
The boned
component allows the model in the render
component to be animated using bone animation. Without this component, the model will stay in its bind pose.
If a specific ID is required, the entity descriptor may optionally accept an id
key with an integer below 65535. If this ID is already taken, this is erroneous.
Once an entity is created, any of its components may be retreived by the script for inspection or modification with game.getcomponent
:
local p = game.getcomponent(entity_id, "physics")
local player_position = p.pos
local player_quaternion = p.rot
-- Double player speed
p.speed = p.speed * 2
A component object is a view to memory; it must not be saved across game ticks, or else you risk corrupting memory. Always call game.getcomponent
again instead of storing it somewhere.
game.getcomponent(ent: integer, type: string): userdata
Loads a specified entity component from the entity subsystem. This is not the entity descriptor table passed to game.addentity
, however the structure is near-identical. Do not reuse the returned value across multiple game updates, else you risk corrupting memory.
-- Move entity 0 to the spawn point
game.getcomponent(0, "physics").pos = {0, 5, 0}
game.addentity(desc: table): integer
Creates an entity from an entity descriptor and adds it to the entity subsystem. Returns the entity ID, that stays constant until the entity is removed. Entity IDs may be reused. k4 is currently hardcoded to support a maximum of 65535 entities.
game.addentity{
render = {mdl = "modelname"},
physics = {
dynamics = "dynamic",
capsule = {radius = 0.24, length = 1.3},
pos = {0, 5, 0},
rot = {0, 0, 0, 1},
trigger = 0,
speed = 6.5
},
movement = {
dir = {0, 0, 0}
}
}
Triggers
Any physical entity in k4 may have a corresponding integer trigger
field. If the entity comes in contact with any other physical object, a corresponding callback function will be called. These callback may be assigned to the game.triggers
table.
-- Place a large trigger box at the center of the scene
game.addentity{
physics = {
dynamics = "static",
box = {w = 10, h = 10, l = 10},
pos = {0, 0, 0},
trigger = 1
}
}
game.triggers[1] = function(id, e1, e2, event)
print(e1 .. " and " .. e2 .. " have collided")
end
In the callback, id
is the ID of the trigger (1 in the above example). e1
and e2
are the entity IDs which have collided. If the object in collision is actually not an entity, but the static physics mesh, then its entity ID will be nil
. Similarly to controls, event
being 0 marks the start of collision, 2 marks the end, and 1 is for the ticks in between.
k4 supports at most 65535 triggers in the scene. Triggers are 1-indexed, and the ID 0 means a lack thereof.
Killing entities or spawning them within a trigger is not allowed.
Audio
The audio subsystem is designed as a graph of audio processing nodes, which allows you to perform audio effects on or extract data from sounds.
3D sounds are currently unavailable, but are envisioned as being filters on top of mono sounds.
game.mix.sound(src: k3mixsource): k3mixwave
Create a sound from a sound source. This will not automatically play the sound. Sounds must not be shared by multiple parents, or they will sound whack.
local bgm_source = game.ref("stream", "bgm.ogg")
local bgm = game.mix.sound(bgm_source)
game.mix.play(wav: k3mixwave): k3mixwave
Play a sound wave. Returns the same sound wave.
game.mix.play(bgm)
game.mix.stop(wav: k3mixwave): k3mixwave
Stop a sound wave. Returns the same sound wave.
game.mix.stop(bgm)
game.mix.power(child: k3mixwave): k3mixpower
Create a power measurement node. A k3mixpower
object has an rms
field which stores the currently measured RMS value of the child
sound wave within the last ???
milliseconds. This value is computed only when requested. If the power node is not playing, rms
will be 0.
local p = game.mix.power(bgm)
print(p.rms)
game.mix.queue(): k3mixqueue
Create a queue of sound waves. Sound waves will be played one by one with seamless transitions in between. If the loop
field of the currently active sound wave is set to true
, then the queue will never advance to the next, without either setting said loop
field to false
, removing it from the queue manually or clearing the queue. Queues inherit k3mixwave
and will similarly not play without game.mix.play
.
local q = game.mix.queue()
k3mixqueue:clear()
Clear a queue. If it is currently playing, the sound will abruptly stop.
q:clear()
k3mixqueue:add(child: k3mixwave)
Add child
to a queue. If the queue is currently playing and it was empty prior to this call, it will begin playing immediately.
q:add(bgm)
Clipboard
The clipboard interface is quite self-explanatory. Drag & dropping files is a planned feature.
-- Get text from the clipboard
print(game.clipboard.text)
-- Override text in the clipboard
game.clipboard.text = "pnis Ayy lmao"
Networking
A k4 scene can easily transition from singleplayer to multiplayer by calling either game.net.host
or game.net.join
.
For hosting, k4 will attempt to automatically open a port through either the PCP or NAT-PMP protocols (unimplemented). The server generates a "peercode" with game.net.gen_peercode
, which returns a string describing how a client may find the server through the Information Superhighway.
However, automatic port forwarding is likely to fail due to poor adoption of both PCP and NAT-PMP. Should this occur, players will be forced to manually establish connections via what is known as hole punching. To do this, the client should also generate his/her peercode with game.net.gen_peercode
. The player responsible for the client should then pass this peercode to the host player. The host side should then "punch a hole" through its NAT to the client using game.net.punch
. Once said function is called, it may become possible for the client to connect.
If the server turns out to be behind a symmetric NAT (formally, an address and port-dependent mapping), then game.net.gen_peercode
will also fail, in which case either a port must be manually opened, or a different player must host the game.
Server-side:
game.net.host()
-- If manual hosting, get some_clients_peercode (e.g via the clipboard), then do
game.net.punch(some_clients_peercode)
Client-side:
game.net.join(server_peercode)
Once a connection is established, the game.join
handler will be called by k4 with a k4peer
object representing the connection to the newly-joined client. Messages may be sent to a client by calling k4peer:send
. The server may broadcast messages to all players (excluding itself) with game.send
. For clients, game.send
sends only to the server. Clients cannot send messages to other clients.
Messages may be received by assigning a handler to game.receive
.
Messages can be any serializable Lua types (no lightuserdata
, userdata
, thread
and function
types).
function game.join(cli)
cli:send("Ping")
end
function game.receive(cli, msg)
if msg == "Ping" then
cli:send("Pong")
end
end
While k4 itself provides seamless transitions to multiplayer, this still requires effort from the script writer.
Firstly, k4 employs client-side prediction, which means the client will keep the simulation running without waiting for the server to send the authorative state. As this is a prediction, it can be wrong, in which case the client will be forced to rollback the state to what the server sent. This means that, for example, a player can enter a trigger and suddenly exit it because of a misprediction. Meanwhile on the server, the player could have never entered the trigger at all.
Secondly, k4 employs server reconciliation, which means the client will try prediction again after receiving the authorative state from the server, using the recent player inputs. Using the same example, this means a player could enter a trigger, exit it, and enter it again, while on the server the player could've entered it only once.
Thirdly, client-side prediction is done only for k4's own entity system, which ignores any properties that could be defined by scripts, e.g. player health. This means if a script's logic depends on things that could be mispredicted, divergence from the server state is inevitable.
Accounting for all of this, to write multiplayer-compliant scripts, one must take into account the following rule: all major gameplay events must be explicitly confirmed by the server through messages instead of being predicted by the client. Clients should allow an entity's health to go below 0 without killing it, servers should periodically send true entity healths and announce kills in case clients missed it. A script can know if it is authorative with the boolean game.authority
.
game.net.gen_peercode(on_receive: function)
Generate a peercode. The data stored within may contain the peer's external IP address and local IP address, for both IPv4 and IPv6. The value is returned in a callback.
game.net.gen_peercode(function(peercode, err)
-- ... get peercode to other player ...
end)
game.net.join(other_peercode: string)
Assume client configuration. Attempt to join a server using its peercode. Returns a boolean with a success status. If the peercode is invalid, throws an error.
game.net.join(server_peercode)
game.send(msg: any)
If a client, send to the server. If a server, broadcast to all clients.
game.send("Yo")
game.net.host(other_peercode: string)
Assume server configuration. Attempt to bind socket and begin hosting. Returns a boolean with a success status.
game.net.host()
game.net.punch(client_peercode: string)
Attempt to punch a hole to a client. Peercode must be manually sent to the host through a different channel. If the peercode is invalid, throws an error. This is necessary only if using manual punching.
game.net.punch(client_peercode)
k4peer:send(msg: any)
Send a message to a peer.
cli:send("Yo")
Creating resources for k4
All assets in the resource system are loaded from a prioritized list of sources, one of which is the assets
directory. All graphical resources (models, materials and textures) are within the subdirectory mdl
, audio files are within aud
and raw triangle meshes are within phys
. It is planned to allow archive files to be sources in a future version of k4.
return {
skinning = false,
primitive = {
diffuse = {0.5, 0.5, 0.5, 1},
specular = {0, 0, 0, 1},
emission = {0, 0, 0, 0},
shininess = 16,
},
{
{
glsl = {
vs = {330, "regular.vs.glsl"},
fs = {330, "regular.fs.glsl"},
defs = {GLCORE = ""},
u = {u_dif = 0},
},
units = {
{tex = "checkers.dif.png"},
}
}
},
{
{
glsl = {
vs = {120, "regular.vs.glsl"},
fs = {120, "regular.fs.glsl"},
defs = {},
u = {u_dif = 0},
},
units = {
{tex = "checkers.dif.png"},
}
}
},
{
{
units = {
{tex = "checkers.dif.png"}
}
}
},
}
Audio
Audio files must be Ogg containers with the Vorbis codec, and they must be of 44.1kHz sample rate. Audio is streamed from the disk, therefore length will not impact RAM usage.
Materials
Graphics resources are by far the most complex, a consequence of k3's wide hardware compatibility. Materials must specify properties depending on the graphics backend, often duplicate. Most is explained in detail here, but a boilerplate for the common usecase exists.
A k3 model will refer to one or more materials. k4 uses the Lua interface to allow specifying materials, therefore material files are simply Lua scripts. The material is composed of a prioritized list of rendering techniques. k3 will attempt to initialize the first technique, and will use the rest as fallbacks. If all techniques fail, k3 will use the always supported primitive
form of rendering.
In the right-hand example we support three techniques: one with a minimum of OpenGL 3.3, one with a minimum of OpenGL 2.1 and the last for the fixed-function pipeline. Because Mesa is stupid, core-profile rendering is strictly equivalent to an OpenGL version equal to or above 3.3, even though this isn't required by the OpenGL standard.
In each technique we have a list of rendering passes, the maximum of which is currently hardcoded to 1. The render pass may specify certain rendering options such as additive rendering.
Models
k4's graphics engine, k3, has a standard file format for models. Models currently support position, UV, color and bone attributes, but it is planned to allow generic attributes.
An export script is available for Blender 2.79, and another script exists for converting from the GLTF2 file format. Beware, because k3 is more or less advanced in different areas compared to either GLTF2 or Blender, most properties are completely ignored by the scripts. For example, only the names of materials are transferred, so k4 may find it within it's own resources. All textures attached to the material are ignored, and you must specify them manually in the material files.
Textures
Texture filenames must be suffixed with one of .dif.png
for diffuse textures, .nrm.png
for normal textures, .dsp.png
for displacement textures, .emt.png
for emissive textures, .rgh.png
for roughness textures and .alp.png
for alpha textures. These are necessary so k3 may properly interpret the colors and render them.
Cubemap textures are needed for, for example, skyboxes. Although a k4 script will load cubemap textures with a .cub.png
suffix (game.ref("tex", "sky1.cub.png")
), you actually need six files in the filesystem, one for each side of the cube: .px.png
for the +X side, .py.png
— +Y, .pz.png
— +Z, .nx.png
— -X, .ny.png
— -Y, .nz.png
— -Z.
Fonts
Currently, k4 supports bitmap fonts based on BMFont by AngelCode.
Getting started
For the majority of cases, the vanilla distribution of k4 is enough to begin scripting. It is built to run on at least the Pentium 4. It contains boilerplate resources, sample 3D models to play with and a starting script with a simple 3D scene. Note that k4 remains in an alpha state, and breaking changes will be common.
But it is even encouraged to modify k4 itself, if you need the performance. For example, the voxel demo shows bad hiccups when placing or removing blocks, because the Lua script is the bottleneck in regenerating chunk models. This shows the need for a voxel extension to k4 (and k3).
Support
Yeah
TODO list in order of highest to lowest priority:
- 3D sounds
- Customizable rendering pipeline
- Resource archives
- OpenGL ES support