In our last post, we presented the new event system in Vortex and how it allows scripts to receive and react to input events. This week we want to put this system to the test by showing how an interactive playground could be implemented. Enter Vortex Pong!
To see the playground in action, please check out the video above. In the rest of this post, I’m going to break down how the project was built and how the script running the simulation works.
Visual Layout of the World
We first started by modeling the environment. Visuals are not a requirement for a pong-like game, as long as you have two paddles and a ball, but we also wanted to show how easy it is to have realtime dynamic lights in the scene.
Vortex Engine enables visually creating the layout of the world and objects inside it. For representing the ball, we used a simple sphere primitive, whereas the rest of the geometry was assembled from scaled cubes.
The Vortex Editor allows easily resizing the meshes into the appropriate shapes and creating materials that interact with the lights. We also used the editor to place the lights in the scene.
No matter how good we might be able to make the world look, it won’t do a lot without adding logic to it.
In Vortex, we use Lua for scripting. The Engine provides a runtime to load and execute scripts in the project and it exposes an API that can be used to run a simulation loop and handle events – This is really all that’s needed for this playground!
The Problem Space
We don’t want to go overboard with our implementation, so we will keep everything simple by having 3 functions and a tiny self-contained vector math library.
The ball will bounce around, updated in our simulation function. We want to support having two players. For this, we will store the state of the keyboard as events arrive, and update the paddle positions also from our simulation loop.
Lua is a pretty great language and in under 200 lines of code we are able to build everything we need for this.
Initialization happens once at the beginning of the script execution and it is responsible for setting up the main camera, registering callbacks for handling on_frame and on_event messages and finding and caching entities of interest.
function main() -- register ourselves for the engine callbacks: table.insert( vtx.callbacks.on_frame, on_frame ) table.insert( vtx.callbacks.on_event, on_event ) -- set the main camera (this is also needed for events to be sent to us) local cam_entity = vtx.find_first_entity_by_name( "main_cam" ) local cam = cam_entity:first_component_of_type( TYPE_CAMERA ) vtx.rendering.set_main_camera( cam ) -- find entities of interest and cache their transforms: local ball = vtx.find_first_entity_by_name( "ball" ) ball_xform = ball:get_transform() local bsx, bsy, bsz = ball_xform:get_scale() ball_scale = bsx local paddle_left = vtx.find_first_entity_by_name( "paddle_left" ) paddle_left_xform = paddle_left:get_transform() local paddle_right = vtx.find_first_entity_by_name( "paddle_right" ) paddle_right_xform = paddle_right:get_transform() -- get and store the position and bounds of the world local world_container = vtx.find_first_entity_by_name( "world_container" ) world_container_xform = world_container:get_transform() local wx, wy, wz, ww = world_container_xform:get_position() world_center = vec2.new( wx, wy ) local wsx, wsy, wsz = world_container_xform:get_scale() world_size = vec2.new( wsx, wsy ) -- start the ball (we could randomize this) ball_dir = vec2.random_non_zero():normalize() -- find animated lights: local ball_light = vtx.find_first_entity_by_name( "ball_light" ) ball_light_xform = ball_light:get_transform() local paddle_left_light = vtx.find_first_entity_by_name( "paddle_left_light0" ) paddle_left_light_xform = paddle_left_light:get_transform() local paddle_right_light = vtx.find_first_entity_by_name( "paddle_right_light0" ) paddle_right_light_xform = paddle_right_light:get_transform() end
Notice how the first two lines add our script functions to the engine’s on_frame and on_event callback list. This is the key for building an interactive simulation.
Handling events is pretty simple. We will hold a global table that stores which keys are currently pressed down on the keyboard. We do not update any paddle positions here. These updates will be handled in the simulation function.
function on_event( evt ) if evt.type == EVT_TYPE_KEYDOWN then pressed_keys[ evt.key ] = true elseif evt.type == EVT_TYPE_KEYUP then pressed_keys[ evt.key ] = nil end end
The simulation is the most complicated function in this example. It has several responsibilities, including updating everything that is moving, detecting collisions against the world and the paddles, and resetting the game in case of a player scoring.
In the context of this example, we did not dig in deep into possible optimizations other than avoiding unnecessary table allocations. ALU (CPU time) could be saved by premultiplying radii and sizes by 0.5 as part of the initialization function.
In a real life example we would also want to break up this function into smaller ones with more clearly-defined responsibilities.
function on_frame( delta_t ) --update ball: local x,y,z,w = ball_xform:get_position() local next_x = x + ball_dir.x * delta_t * ball_speed local next_y = y + ball_dir.y * delta_t * ball_speed local bounce_right = next_x + ball_scale * 0.5 >= world_center.x + world_size.x * 0.5 local bounce_left = next_x - ball_scale * 0.5 <= world_center.x - world_size.x * 0.5 if bounce_left == false and bounce_right == false then x = next_x else -- the ball has hit one of the horizontal walls, check for scoring event: if bounce_right then local px, py, pz, pw = paddle_right_xform:get_position() local sx, sy, sz = paddle_right_xform:get_scale() if y <= py + sy * 0.5 and y >= py - sy * 0.5 then ball_dir.x = -ball_dir.x -- saved! else -- score! print( "Score Player 0!" ) ball_dir = vec2.random_non_zero():normalize() x = world_center.x y = world_center.y end elseif bounce_left then local px, py, pz, pw = paddle_left_xform:get_position() local sx, sy, sz = paddle_left_xform:get_scale() if y <= py + sy * 0.5 and y >= py - sy * 0.5 then ball_dir.x = -ball_dir.x -- saved! else -- score! print( "Score Player 1!") ball_dir = vec2.random_non_zero():normalize() x = world_center.x y = world_center.y end end end if next_y + ball_scale * 0.5 >= world_center.y + world_size.y * 0.5 or next_y - ball_scale * 0.5 <= world_center.y - world_size.y * 0.5 then ball_dir.y = -ball_dir.y else y = next_y end ball_xform:set_position( x, y, z, w ) ball_light_xform:set_position( x, y, z, w ) -- update paddles: local plx, ply, plz, plw = paddle_left_xform:get_position() if pressed_keys[ KEY_W ] ~= nil then ply = ply + delta_t * paddle_speed end if pressed_keys[ KEY_S ] ~= nil then ply = ply - delta_t * paddle_speed end paddle_left_xform:set_position( plx, ply, plz, plw ) paddle_left_light_xform:set_position( plx, ply, plz, plw ) local prx, pry, prz, prw = paddle_right_xform:get_position() if pressed_keys[ KEY_UP ] ~= nil then pry = pry + delta_t * paddle_speed end if pressed_keys[ KEY_DOWN ] ~= nil then pry = pry - delta_t * paddle_speed end paddle_right_xform:set_position( prx, pry, prz, prw ) paddle_right_light_xform:set_position( prx, pry, prz, prw ) end
One of the first things you will notice is that all the logic is simulated in 2D, despite this being a 3D world. There is no need to run the simulation in 3D, as we are only interested in what's happening on the plane where the game is being played.
Lines 3-13, 43-47: the way the ball simulation works is by calculating the position where it should be next based on its move vector and the time elapsed since the last update. Instead of updating the ball position immediately, however, we check if the new positions would make us collide against a wall, the floor or the ceiling.
Colliding with the floor and ceiling is trivial: we just mirror the y component of the move vector.
Lines 14-41: Horizontal collisions are move complex, as they require checking if we hit the paddles or not.
Lines 49-50: Assuming no player has scored, we send the updated positions down to the engine via the transform objects we previously cached.
Lines 53-71: Finally, we deal with updating the paddle positions based on what keys are currently pressed. This allows for a very smooth animation experience for the player, much more so than updating the positions directly from the keypress event.
This is really all there is to it. Once everything is set up, we can run the playground from the Editor directly or build it into a Vortex Archive and run it on any Vortex Runtime (any Runtime that has a keyboard attached that is).
If you haven't seen the video above, I recommend you take a look, as I mention a few concepts that I glanced over in this post.
I hope you guys found this post interesting and, as usual, stay tuned for more!