Hi,

I'd like to make some contributions to examples/lua:
1. update notice message in games.cfg to report trisdemo game name
2. quick fix for trisdemo to avoid "blinking" effect
3. an "invaders" game where we have to shoot aliens falling from the sky
4. a "pong" game, which requires 2 players

Baptiste
From 94577208c1a8decf4d51b4f52a48bc21cb61dfa4 Mon Sep 17 00:00:00 2001
From: Baptiste Assmann <bassm...@haproxy.com>
Date: Wed, 23 Apr 2025 11:39:17 +0200
Subject: [PATCH 4/4] NEW: examples/lua: pong like game

Requires a couple of palyers to get connected on the same HAProxy and
then enjoy a simple pong like game.
---
 examples/games.cfg    |   6 ++
 examples/lua/pong.lua | 235 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 241 insertions(+)
 create mode 100644 examples/lua/pong.lua

diff --git a/examples/games.cfg b/examples/games.cfg
index 0720c6cef..73ea77f62 100644
--- a/examples/games.cfg
+++ b/examples/games.cfg
@@ -4,6 +4,7 @@ global
 	# load all games here
 	lua-load lua/trisdemo.lua
 	lua-load lua/invaders.lua
+	lua-load lua/pong.lua
 
 defaults
 	timeout client 1h
@@ -18,3 +19,8 @@ frontend trisdemo
 frontend invaders
 	bind :7002
 	tcp-request content use-service lua.invaders
+
+.notice 'use "socat TCP-CONNECT:0:7003 STDIO,raw,echo=0" to start playing pong (requires 2 players)'
+frontend pong
+	bind :7003
+	tcp-request content use-service lua.pong
diff --git a/examples/lua/pong.lua b/examples/lua/pong.lua
new file mode 100644
index 000000000..84163b322
--- /dev/null
+++ b/examples/lua/pong.lua
@@ -0,0 +1,235 @@
+-- Pong game in Lua for HAProxy 3.2’s TCP service
+
+local debug = false -- Set to true for debug output, false to disable
+
+local game = {
+    width = 40,
+    height = 20,
+    ball = { x = 40, y = 15, dx = 0.1, dy = 0.1 }, -- adjust dx / dy for ball speed
+    paddle1 = { height = 3 }, -- Left paddle (Player 1)
+    paddle2 = { height = 3 }, -- Right paddle (Player 2)
+    score1 = 0,
+    score2 = 0,
+    running = false,
+    last_input = "",
+    applets = {}
+}
+
+-- ANSI escape codes
+local clear_screen = "\27[2J"
+local clear_endofline = "\027[0K"
+local cursor_home = "\27[H"
+local reset_color = "\27[0m"
+
+-- ANSI color codes (from trisdemo)
+local color_codes = {
+    [1] = "\27[1;34m", -- Blue: left paddle -- FIXME: don't know why next string.sub will mess up with colors
+                                            -- basically the player2 paddle draw will mess up screen if player1 has a color
+    [2] = "\27[1;33m", -- Yellow: right paddle
+    [3] = "\27[1;31m", -- Red: ball
+}
+
+local screen = "" -- Global screen state
+
+local function draw_game()
+    if debug then print("draw_game called") end
+
+    -- initialize a blank screen
+    local screen_lines = {}
+    for y = 1, game.height do
+        screen_lines[y] = "|" .. string.rep(" ", game.width) .. "|"
+    end
+
+    -- draw the walls around the game
+    -- horizontal lines
+    screen_lines[1] = "+" .. string.rep("-", game.width) .. "+"
+    screen_lines[game.height + 1] = "+" .. string.rep("-", game.width) .. "+"
+
+    -- left paddle
+    for i = 0, game.paddle1.height - 1 do
+        local y = game.paddle1.y + i
+        if y >= 1 and y <= game.height then
+            -- can't change its color as the sting.sub of paddle2 seem to mess up with ANSI char
+            screen_lines[y] = "| ||" .. string.sub(screen_lines[y], 5)
+            screen_lines[y] = screen_lines[y]
+        end
+    end
+    -- right paddle
+    for i = 0, game.paddle2.height - 1 do
+        local y = game.paddle2.y + i
+        if y >= 1 and y <= game.height then
+            screen_lines[y] = string.sub(screen_lines[y], 1, game.width - 2) .. color_codes[2] .. "||" .. reset_color .. " |"
+        end
+    end
+
+    -- draw ball
+    local ball_x = math.floor(game.ball.x + 0.5)
+    local ball_y = math.floor(game.ball.y + 0.5)
+    if ball_y >= 2 and ball_y <= game.height and
+       ball_x >= 2 and ball_x <= game.width - 1 then
+           screen_lines[ball_y] = string.sub(screen_lines[ball_y], 1, ball_x - 1) .. color_codes[3] .. "O" .. reset_color ..
+                                  string.sub(screen_lines[ball_y], ball_x + 1)
+    end
+
+    screen = "Score: " .. game.score1 .. " - " .. game.score2 .. clear_endofline .. "\r\n"
+    screen = screen .. table.concat(screen_lines, "\r\n") .. "\r\n"
+    screen = screen .. "Player 1: W (up), S (down) | Player 2: I (up), K (down) | Q to quit\r\n"
+    if debug then screen = screen .. "Last input: " .. game.last_input .. "\r\n" end
+end
+
+local function update_game()
+    if debug then print("update_game called, running: " .. tostring(game.running)) end
+    if not game.running then return end
+
+    -- move the ball
+    game.ball.x = game.ball.x + game.ball.dx
+    game.ball.y = game.ball.y + game.ball.dy
+    -- invert ball direction when it hits a wall
+    if game.ball.y <= 2 then
+        game.ball.y = 2
+        game.ball.dy = -game.ball.dy
+    elseif game.ball.y >= game.height then
+        game.ball.y = game.height
+        game.ball.dy = -game.ball.dy
+    end
+
+    -- check if point has been scored
+    if game.ball.x <= 3 then
+        -- player2 just scored a point
+        game.score2 = game.score2 + 1
+        game.ball.x = game.width / 2
+        game.ball.y = game.height / 2
+        game.ball.dx = -game.ball.dx
+    elseif game.ball.x > game.width - 1 then
+        -- player1 just scored a point
+        game.score1 = game.score1 + 1
+        game.ball.x = game.width / 2
+        game.ball.y = game.height / 2
+        game.ball.dx = -game.ball.dx
+    elseif game.ball.x <= 5 and game.ball.y >= game.paddle1.y and game.ball.y <= game.paddle1.y + game.paddle1.height - 1 then
+        -- left player returned the ball
+        game.ball.x = 5
+        game.ball.dx = -game.ball.dx
+    elseif game.ball.x >= game.width - 2 and game.ball.y >= game.paddle2.y and game.ball.y <= game.paddle2.y + game.paddle2.height - 1 then
+        -- right player returned the ball
+        game.ball.x = game.width - 2
+        game.ball.dx = -game.ball.dx
+    end
+end
+
+local function handle_input(applet, input)
+    if debug then print("handle_input called, applet index: " .. (function() for i, a in ipairs(game.applets) do if a == applet then return i end end return "unknown" end)() .. ", input: " .. (input or "nil")) end
+    if not input or input == "" then return true end
+    game.last_input = input
+    input = input:lower()
+
+    if applet.playerid == 1 and input == "w" and game.paddle1.y > 2 then
+        game.paddle1.y = game.paddle1.y - 1
+    elseif applet.playerid == 1 and input == "s" and game.paddle1.y + game.paddle1.height - 1 < game.height then
+        game.paddle1.y = game.paddle1.y + 1
+    elseif applet.playerid == 2 and input == "i" and game.paddle2.y > 2 then
+        game.paddle2.y = game.paddle2.y - 1
+    elseif applet.playerid == 2 and input == "k" and game.paddle2.y + game.paddle2.height - 1 < game.height then
+        game.paddle2.y = game.paddle2.y + 1
+    elseif input == "q" then
+        for i, a in ipairs(game.applets) do
+            if a == applet then
+                if debug then print("Player quitting, removing applet " .. i) end
+                table.remove(game.applets, i)
+                break
+            end
+        end
+        applet:send("Game ended. Final score: " .. game.score1 .. " - " .. game.score2 .. "\r\n")
+        return false
+    end
+    return true
+end
+
+local function game_task()
+    if debug then print("game_task started") end
+
+    -- game initialization
+    game.running = false
+    game.width = game.width + 4  -- adjustment for walls
+    game.height = game.height + 2 -- adjustment for walls
+    game.last_input = ""
+
+    local last_draw_time = 0
+    while true do
+        if debug then print("game_task loop, applets: " .. #game.applets .. ", running: " .. tostring(game.running)) end
+
+        if #game.applets == 0 then
+            -- no players, let's do some reset tasks
+            game.running = false
+            game.score1 = 0
+            game.score2 = 0
+            game.ball = { x = game.width / 2, y = game.height / 2, dx = game.ball.dx, dy = game.ball.dy }
+            game.score1 = 0
+            game.score2 = 0
+            game.paddle1 = { y = game.height / 2, height = game.paddle1.height }
+            game.paddle2 = { y = game.height / 2, height = game.paddle2.height }
+            game.last_input = ""
+            screen = "No players connected.\r\n"
+        elseif #game.applets == 1 then
+            game.running = false
+            screen = "Waiting for Player 2...\r\n"
+        elseif #game.applets == 2 and not game.running then
+            if debug then print("Starting game with 2 players") end
+            game.running = true
+            last_draw_time = (core.now().sec * 1000) + math.floor(core.now().usec / 1000)
+            draw_game()
+        end
+
+        if game.running then
+            update_game()
+            local current_time = (core.now().sec * 1000) + math.floor(core.now().usec / 1000)
+            if debug then print("game_task draw check, time diff: " .. (current_time - last_draw_time)) end
+            if current_time - last_draw_time >= 100 then
+                draw_game()
+                last_draw_time = current_time
+            end
+        end
+
+        core.msleep(20)
+    end
+end
+
+core.register_task(game_task)
+
+function pong_service(applet)
+    if debug then print("pong_service called, adding applet, current count: " .. #game.applets) end
+    table.insert(game.applets, applet)
+    applet.playerid = #game.applets
+
+    if #game.applets == 1 then
+        applet:send("Welcome to Pong! Waiting for Player 2...\r\n")
+    else
+        applet:send("Welcome to Pong! Game starting soon...\r\n")
+    end
+
+    local target_cycle_time = 100 -- 100ms refresh rate
+    applet:send(clear_screen)
+    while true do
+        local start_time = (core.now().sec * 1000) + math.floor(core.now().usec / 1000)
+
+        local receive_start = (core.now().sec * 1000) + math.floor(core.now().usec / 1000)
+        local input = applet:receive(1, 90)
+        local receive_end = (core.now().sec * 1000) + math.floor(core.now().usec / 1000)
+        if debug then print("pong_service receive waited " .. (receive_end - receive_start) .. "ms for applet " .. applet.playerid) end
+
+        if debug then print("pong_service sending screen to applet " .. applet.playerid) end
+        applet:send(cursor_home .. screen)
+
+        if not handle_input(applet, input) then break end
+
+        local end_time = (core.now().sec * 1000) + math.floor(core.now().usec / 1000)
+        local elapsed = end_time - start_time
+        local sleep_time = target_cycle_time - elapsed
+        if sleep_time > 0 then
+            if debug then print("pong_service sleep time: " .. sleep_time .. "ms for applet " .. applet.playerid) end
+            core.msleep(sleep_time)
+        end
+    end
+end
+
+core.register_service("pong", "tcp", pong_service)
-- 
2.43.0

From 65cd901bc78bc796239b5da13970c93519f9bb36 Mon Sep 17 00:00:00 2001
From: Baptiste Assmann <bassm...@haproxy.com>
Date: Wed, 23 Apr 2025 10:36:37 +0200
Subject: [PATCH 2/4] trisdemo: avoid screen refresh effect

In current version of the game, there is a "screen refresh" effect: the
screen is cleared before being re-drawn.
I moved the clear right after the connection is opened and removed it
from rendering time.
---
 examples/lua/trisdemo.lua | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/examples/lua/trisdemo.lua b/examples/lua/trisdemo.lua
index 7156df98d..4b60bb5ac 100644
--- a/examples/lua/trisdemo.lua
+++ b/examples/lua/trisdemo.lua
@@ -112,7 +112,7 @@ local function rotate_piece(piece, piece_id, px, py, board)
 end
 
 function render(applet, board, piece, piece_id, px, py, score)
-    local output = clear_screen .. cursor_home
+    local output = cursor_home
     output = output .. game_name .. " - Lines: " .. score .. "\r\n"
     output = output .. "+" .. string.rep("-", board_width * 2) .. "+\r\n"
     for y = 1, board_height do
@@ -160,6 +160,7 @@ function handler(applet)
     end
 
     applet:send(cursor_hide)
+    applet:send(clear_screen)
 
     -- fall the piece by one line every delay
     local function fall_piece()
-- 
2.43.0

From 5805c025436475976cc70b69e1d6adc04c8a252e Mon Sep 17 00:00:00 2001
From: Baptiste Assmann <bassm...@haproxy.com>
Date: Tue, 22 Apr 2025 12:14:51 +0200
Subject: [PATCH 1/4] games.cfg: add game name to the notice message

---
 examples/games.cfg | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/examples/games.cfg b/examples/games.cfg
index b0f991227..54f7036c3 100644
--- a/examples/games.cfg
+++ b/examples/games.cfg
@@ -8,7 +8,7 @@ defaults
 	timeout client 1h
 
 # map one TCP port to each game
-.notice 'use "socat TCP-CONNECT:0:7001 STDIO,raw,echo=0" to start playing'
+.notice 'use "socat TCP-CONNECT:0:7001 STDIO,raw,echo=0" to start playing trisdemo'
 frontend trisdemo
 	bind :7001
 	tcp-request content use-service lua.trisdemo
-- 
2.43.0

From ca97c5f4618826a50acf268eb9c3599b02d0f3b2 Mon Sep 17 00:00:00 2001
From: Baptiste Assmann <bassm...@haproxy.com>
Date: Tue, 22 Apr 2025 12:14:00 +0200
Subject: [PATCH 3/4] NEW: examples/lua: space invaders game

Kill the invaders while the fall down from the sky.
---
 examples/games.cfg        |   6 ++
 examples/lua/invaders.lua | 213 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 219 insertions(+)
 create mode 100644 examples/lua/invaders.lua

diff --git a/examples/games.cfg b/examples/games.cfg
index 54f7036c3..0720c6cef 100644
--- a/examples/games.cfg
+++ b/examples/games.cfg
@@ -3,6 +3,7 @@ global
 	tune.lua.bool-sample-conversion normal
 	# load all games here
 	lua-load lua/trisdemo.lua
+	lua-load lua/invaders.lua
 
 defaults
 	timeout client 1h
@@ -12,3 +13,8 @@ defaults
 frontend trisdemo
 	bind :7001
 	tcp-request content use-service lua.trisdemo
+
+.notice 'use "socat TCP-CONNECT:0:7002 STDIO,raw,echo=0" to start playing invaders'
+frontend invaders
+	bind :7002
+	tcp-request content use-service lua.invaders
diff --git a/examples/lua/invaders.lua b/examples/lua/invaders.lua
new file mode 100644
index 000000000..e278e647a
--- /dev/null
+++ b/examples/lua/invaders.lua
@@ -0,0 +1,213 @@
+-- Space Invaders-like game in Lua for HAProxy 3.2 TCP service
+
+local debug = false -- Set to true for debug output, false to disable
+
+-- ANSI escape codes
+local clear_screen = "\27[2J"
+local cursor_home = "\27[H"
+local reset_color = "\27[0m"
+
+-- ANSI color codes (from trisdemo)
+local color_codes = {
+    [1] = "\27[1;31m", -- Red: shoot
+    [2] = "\27[1;34m", -- Blue: space ship
+    [3] = "\27[1;35m", -- Purple: invaders
+}
+
+local function init_game()
+    return {
+        width = 40, -- wider screen is possible, but must update the init_invaders() function
+        height = 20,
+        ship = { y = 20 }, -- Ship starts in middle column, bottom row
+        invaders = {}, -- List of invaders {x, y}
+        shots = {}, -- List of player shots {x, y}
+        score = 0,
+        running = false,
+        last_input = "",
+        last_move_time = 0
+    }
+end
+
+-- delay (in ms) between 2 invaders moves
+-- once all invaders are dead, level increases and delay is reduced
+local invaders_move = 5000
+local game_level = 0
+
+-- Initialize invaders at game start
+local function init_invaders(game)
+    game.invaders = {}
+    for row = 1, 3 do
+        for col = 5, 35, 5 do -- should be adjusted based on width
+            table.insert(game.invaders, { x = row, y = col })
+        end
+    end
+end
+
+local function draw_game(game)
+    if debug then print("draw_game called") end
+
+    -- initialize a blank screen
+    local screen_lines = {}
+    for y = 1, game.height do
+        screen_lines[y] = "|" .. string.rep(" ", game.width - 1) .. "|"
+    end
+
+    -- draw the frame around the game
+    -- horizontal lines
+    screen_lines[1] = "+" .. string.rep("-", game.width - 1) .. "+"
+    screen_lines[game.height + 1] = "+" .. string.rep("-", game.width - 1) .. "+"
+
+    -- Draw ship at bottom row
+    screen_lines[game.height] = string.sub(screen_lines[game.height], 1, game.ship.y - 1) .. color_codes[2] .. "^" .. reset_color ..
+                                string.sub(screen_lines[game.height], game.ship.y + 1)
+
+    -- Draw invaders
+    for _, invader in ipairs(game.invaders) do
+        if invader.x >= 1 and invader.x <= game.height and invader.y >= 1 and invader.y <= game.width then
+            screen_lines[invader.x] = string.sub(screen_lines[invader.x], 1, invader.y - 1) .. "V" ..
+                                      string.sub(screen_lines[invader.x], invader.y + 1)
+        end
+    end
+
+    -- Draw shots
+    for _, shot in ipairs(game.shots) do
+        if shot.x >= 1 and shot.x <= game.height and shot.y >= 1 and shot.y <= game.width then
+            screen_lines[shot.x] = string.sub(screen_lines[shot.x], 1, shot.y - 1) .. color_codes[1] .. "|" .. reset_color ..
+                                   string.sub(screen_lines[shot.x], shot.y + 1)
+        end
+    end
+
+    local screen = "Level: " .. game_level .. ", Score: " .. game.score .. "\r\n"
+    screen = screen .. table.concat(screen_lines, "\r\n") .. "\r\n"
+    screen = screen .. "Controls: Left and Right arrows, Space (shoot), Q (quit)\r\n"
+    if debug then screen = screen .. "Last input: " .. game.last_input .. "\r\n" end
+    return screen
+end
+
+local function update_game(game)
+    if debug then print("update_game called, running: " .. tostring(game.running)) end
+    if not game.running then return end
+
+    -- Move shots up every cycle (200ms)
+    for i = #game.shots, 1, -1 do
+        game.shots[i].x = game.shots[i].x - 1
+        if game.shots[i].x < 1 then
+            table.remove(game.shots, i)
+        end
+    end
+
+    -- Check collisions every cycle
+    local hits = {}
+    for s, shot in ipairs(game.shots) do
+        for i, invader in ipairs(game.invaders) do
+            if shot.x == invader.x and shot.y == invader.y then
+                if debug then print("Hit detected: Shot " .. s .. " at x=" .. shot.x .. ", y=" .. shot.y .. " vs Invader " .. i .. " at x=" .. invader.x .. ", y=" .. invader.y) end
+                table.insert(hits, { shot = s, invader = i })
+            end
+        end
+    end
+
+    -- Remove hits in reverse order
+    table.sort(hits, function(a, b) return a.invader > b.invader end)
+    for _, hit in ipairs(hits) do
+        table.remove(game.shots, hit.shot)
+        table.remove(game.invaders, hit.invader)
+        game.score = game.score + 10
+    end
+
+    -- Check for win condition
+    if #game.invaders == 0 then
+        --game.running = false
+        game_level = game_level + 1
+        init_invaders(game)
+        if game_level >= 5 then
+            return "You Win! Level: " .. game_level .. ", Final Score: " .. game.score .. "\r\n"
+        end
+    end
+
+    -- Move invaders down
+    -- Change this value to "accelerate" the game: 500ms faster for each level
+    local current_time = (core.now().sec * 1000) + math.floor(core.now().usec / 1000)
+    if current_time - game.last_move_time >= (invaders_move - game_level * 500)  then
+        for i = #game.invaders, 1, -1 do
+            game.invaders[i].x = game.invaders[i].x + 1
+            if game.invaders[i].x >= game.height then
+                game.running = false
+                return "Game Over! Level: " .. game_level .. ", Final Score: " .. game.score .. "\r\n"
+            end
+        end
+        game.last_move_time = current_time
+    end
+end
+
+local function handle_input(game, applet, input)
+    if debug then print("handle_input called, input: " .. (input or "nil")) end
+    if not input or input == "" then return true end
+    game.last_input = input
+    local delay = 10
+
+    if input == "q" then
+        applet:send("Game ended. Level: " .. game_level .. ", Final score: " .. game.score .. "\r\n")
+        return false
+    elseif input == " " then -- Spacebar to shoot
+        table.insert(game.shots, { x = game.height - 1, y = game.ship.y })
+    elseif input == "\27" then
+        local a = applet:receive(1, delay)
+        if a == "[" then
+            local b = applet:receive(1, delay)
+            if b == "C" and game.ship.y < game.width then -- Right arrow
+                        game.ship.y = game.ship.y + 1
+            elseif b == "D" and game.ship.y > 2 then -- Left arrow
+                game.ship.y = game.ship.y - 1
+            end
+        end
+    end
+    return true
+end
+
+function invaders_service(applet)
+    if debug then print("invaders_service called") end
+
+    local game = init_game()
+    applet:send("Welcome to Space Invaders! Game starting...\r\n")
+    core.msleep(1000)
+
+    game.running = true
+    init_invaders(game)
+    local target_cycle_time = 200
+
+    applet:send(clear_screen)
+    while true do
+        local start_time = (core.now().sec * 1000) + math.floor(core.now().usec / 1000)
+
+        local receive_start = (core.now().sec * 1000) + math.floor(core.now().usec / 1000)
+        local input = applet:receive(1, 190)
+        local receive_end = (core.now().sec * 1000) + math.floor(core.now().usec / 1000)
+        if debug then
+            print("invaders_service receive waited " .. (receive_end - receive_start) .. "ms")
+            print("Input received: " .. (input or "nil"))
+        end
+
+        local screen_update = update_game(game)
+        if screen_update then
+            applet:send(cursor_home .. screen_update)
+            break
+        end
+
+        if debug then print("invaders_service sending screen") end
+        local screen = draw_game(game)
+        applet:send(cursor_home .. screen)
+
+        if not handle_input(game, applet, input) then break end
+
+        local end_time = (core.now().sec * 1000) + math.floor(core.now().usec / 1000)
+        local elapsed = end_time - start_time
+        local sleep_time = target_cycle_time - elapsed
+        if sleep_time > 0 then
+            if debug then print("invaders_service sleep time: " .. sleep_time .. "ms") end
+            core.msleep(sleep_time)
+        end
+    end
+end
+
+core.register_service("invaders", "tcp", invaders_service)
-- 
2.43.0

Reply via email to